From 0747ce0bc1e7d6f40d56977bf64117642f05652c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:26:15 -0400 Subject: [PATCH] Publish FastMCP 4.0.0b1 docs to gofastmcp.com (#4695) --- .claude/skills/review-pr/SKILL.md | 7 +- .github/workflows/marvin-dedupe-issues.yml | 15 + .github/workflows/marvin-label-triage.yml | 63 +- .github/workflows/marvin-test-failure.yml | 2 +- .github/workflows/publish-fastmcp-tasks.yml | 17 + .github/workflows/publish-fastmcp.yml | 11 +- CLAUDE.md | 8 +- README.md | 7 +- .../v3-notes/auth-provider-env-vars.md | 0 .../v3-notes}/get-methods-consolidation.md | 0 .../v3-notes}/prompt-internal-types.md | 0 .../v3-notes}/provider-architecture.md | 0 .../v3-notes}/provider-test-pattern.md | 0 .../v3-notes}/resource-internal-types.md | 0 .../v3-notes}/task-meta-parameter.md | 0 .../v3-notes/v3-features.md | 26 +- {v3-notes => dev-docs/v3-notes}/visibility.md | 0 .../v4-notes/background-tasks.md | 4 +- .../v4-notes/change-register.md | 96 +- .../v4-notes/feature-program.md | 30 +- .../index.mdx => dev-docs/v4-notes/index.md | 14 +- .../v4-notes/known-gaps.md | 6 +- .../v4-notes/protocol-2026.md | 10 +- .../v4-notes/stateless-session-state.md | 0 docs/apps/architecture.mdx | 38 +- docs/apps/fastmcp-app.mdx | 6 +- docs/apps/low-level.mdx | 8 +- docs/changelog.mdx | 196 ++ docs/clients/client.mdx | 12 +- docs/clients/elicitation.mdx | 4 +- docs/clients/notifications.mdx | 6 +- docs/clients/roots.mdx | 18 +- docs/clients/sampling.mdx | 100 +- docs/deployment/http.mdx | 46 +- docs/docs.json | 62 +- docs/getting-started/installation.mdx | 12 +- .../upgrading/from-fastmcp-2.mdx | 58 +- .../upgrading/from-fastmcp-3.mdx | 316 ++- ...evel-sdk.mdx => from-low-level-sdk-v1.mdx} | 165 +- .../upgrading/from-low-level-sdk-v2.mdx | 622 +++++ .../upgrading/from-mcp-sdk-v1.mdx | 264 ++ .../upgrading/from-mcp-sdk-v2.mdx | 328 +++ .../upgrading/from-mcp-sdk.mdx | 166 -- docs/getting-started/whats-new.mdx | 57 +- docs/integrations/auth0.mdx | 2 +- docs/integrations/chatgpt.mdx | 2 +- docs/integrations/descope.mdx | 2 + docs/integrations/openapi.mdx | 19 +- docs/more/faq.mdx | 109 +- docs/more/settings.mdx | 4 +- docs/python-sdk/fastmcp-exceptions.mdx | 15 +- .../fastmcp-utilities-authorization.mdx | 101 +- .../fastmcp-utilities-json_schema.mdx | 8 +- docs/python-sdk/fastmcp-utilities-types.mdx | 10 +- docs/servers/auth/authentication.mdx | 14 +- docs/servers/auth/multi-auth.mdx | 14 +- docs/servers/auth/oauth-proxy.mdx | 35 +- docs/servers/auth/oidc-proxy.mdx | 9 + docs/servers/authorization.mdx | 124 +- docs/servers/completions.mdx | 10 +- docs/servers/context.mdx | 16 +- docs/servers/elicitation.mdx | 67 +- docs/servers/icons.mdx | 14 +- docs/servers/prompts.mdx | 4 + docs/servers/providers/proxy.mdx | 2 + docs/servers/resources.mdx | 4 + docs/servers/sampling.mdx | 614 +---- docs/servers/server.mdx | 14 +- docs/servers/tasks.mdx | 2 +- docs/servers/telemetry.mdx | 55 +- docs/servers/tools.mdx | 31 +- docs/servers/transforms/code-mode.mdx | 2 +- docs/servers/transforms/tool-search.mdx | 2 + docs/servers/transforms/transforms.mdx | 2 +- docs/updates.mdx | 32 + examples/sampling/README.md | 62 - examples/sampling/server_fallback.py | 88 - examples/sampling/structured_output.py | 110 - examples/sampling/text.py | 78 - examples/sampling/tool_use.py | 125 - examples/skills/download_skills.py | 2 +- examples/testing_demo/uv.lock | 22 +- fastmcp_slim/README.md | 7 +- fastmcp_slim/fastmcp/apps/app.py | 41 +- fastmcp_slim/fastmcp/apps/config.py | 29 + fastmcp_slim/fastmcp/cli/discovery.py | 6 +- .../fastmcp/client/auth/client_credentials.py | 4 +- fastmcp_slim/fastmcp/client/auth/oauth.py | 1 - fastmcp_slim/fastmcp/client/client.py | 62 +- fastmcp_slim/fastmcp/client/messages.py | 61 +- fastmcp_slim/fastmcp/client/progress.py | 2 +- fastmcp_slim/fastmcp/client/roots.py | 2 +- .../client/sampling/handlers/anthropic.py | 6 +- .../client/sampling/handlers/google_genai.py | 10 +- .../fastmcp/client/transports/base.py | 1 + fastmcp_slim/fastmcp/exceptions.py | 24 + .../fastmcp/experimental/sampling/__init__.py | 0 .../sampling/handlers/__init__.py | 5 - .../experimental/sampling/handlers/openai.py | 5 - fastmcp_slim/fastmcp/prompts/__init__.py | 8 - fastmcp_slim/fastmcp/prompts/base.py | 39 + fastmcp_slim/fastmcp/resources/__init__.py | 8 - fastmcp_slim/fastmcp/resources/base.py | 35 + fastmcp_slim/fastmcp/server/auth/__init__.py | 4 +- fastmcp_slim/fastmcp/server/auth/auth.py | 56 +- .../fastmcp/server/auth/authorization.py | 17 - .../fastmcp/server/auth/handlers/authorize.py | 9 +- .../fastmcp/server/auth/identity_assertion.py | 12 +- .../server/auth/oauth_proxy/consent.py | 4 +- .../fastmcp/server/auth/oauth_proxy/models.py | 28 +- .../fastmcp/server/auth/oauth_proxy/proxy.py | 187 +- .../fastmcp/server/auth/oidc_proxy.py | 29 +- .../server/auth/providers/huggingface.py | 3 + .../server/auth/providers/in_memory.py | 3 + .../fastmcp/server/auth/providers/oci.py | 5 +- .../server/auth/redirect_validation.py | 110 +- fastmcp_slim/fastmcp/server/context.py | 340 +-- fastmcp_slim/fastmcp/server/elicitation.py | 33 +- .../server/middleware/authorization.py | 104 +- .../fastmcp/server/middleware/caching.py | 72 +- .../fastmcp/server/mixins/mcp_operations.py | 48 +- .../fastmcp/server/providers/__init__.py | 2 - .../fastmcp/server/providers/addressing.py | 12 +- .../fastmcp/server/providers/aggregate.py | 32 +- fastmcp_slim/fastmcp/server/providers/base.py | 6 +- .../local_provider/decorators/prompts.py | 2 +- .../local_provider/decorators/resources.py | 2 +- .../local_provider/decorators/tools.py | 2 +- .../server/providers/prefab_payload.py | 158 ++ .../server/providers/prefab_synthesis.py | 5 +- .../fastmcp/server/providers/proxy.py | 228 +- .../server/providers/skills/__init__.py | 5 - .../fastmcp/server/sampling/__init__.py | 10 - fastmcp_slim/fastmcp/server/sampling/run.py | 836 ------ .../fastmcp/server/sampling/sampling_tool.py | 204 -- fastmcp_slim/fastmcp/server/server.py | 203 +- fastmcp_slim/fastmcp/server/telemetry.py | 56 +- .../fastmcp/server/transforms/catalog.py | 14 +- .../fastmcp/server/transforms/search/base.py | 8 + fastmcp_slim/fastmcp/settings.py | 45 +- fastmcp_slim/fastmcp/telemetry.py | 87 +- fastmcp_slim/fastmcp/tools/__init__.py | 8 - fastmcp_slim/fastmcp/tools/base.py | 14 +- fastmcp_slim/fastmcp/tools/tool_transform.py | 46 +- .../fastmcp/utilities/authorization.py | 277 +- fastmcp_slim/fastmcp/utilities/inspect.py | 4 +- fastmcp_slim/fastmcp/utilities/json_schema.py | 54 +- .../fastmcp/utilities/openapi/parser.py | 42 +- .../fastmcp/utilities/openapi/schemas.py | 175 +- fastmcp_slim/fastmcp/utilities/types.py | 21 +- fastmcp_slim/pyproject.toml | 4 +- fastmcp_tasks/fastmcp_tasks/extension.py | 48 +- fastmcp_tasks/fastmcp_tasks/handlers.py | 85 +- fastmcp_tasks/fastmcp_tasks/input_loop.py | 10 + fastmcp_tasks/fastmcp_tasks/input_store.py | 60 +- fastmcp_tasks/fastmcp_tasks/models.py | 11 +- pyproject.toml | 6 +- tests/apps/test_file_upload.py | 10 +- tests/cli/test_discovery.py | 34 + tests/client/client/test_client.py | 5 +- tests/client/client/test_error_handling.py | 38 +- tests/client/client/test_mode_negotiation.py | 5 +- tests/client/test_elicitation.py | 119 +- tests/client/test_roots.py | 67 +- tests/client/test_sampling.py | 223 +- tests/client/test_sampling_result_types.py | 681 ----- tests/client/test_sampling_tool_loop.py | 811 ------ tests/conformance/expected-failures.yml | 23 +- tests/conformance/server.py | 523 +++- tests/conformance/test_conformance.py | 25 +- tests/conftest.py | 16 + tests/deprecated/test_elicitation.py | 33 - tests/docs/test_upgrade_guide_api_claims.py | 250 ++ tests/docs/test_upgrade_guide_equivalence.py | 239 ++ tests/docs/test_upgrade_guide_examples.py | 101 + tests/resources/test_resources.py | 5 +- .../auth/oauth_proxy/test_authorization.py | 61 + .../oauth_proxy/test_client_registration.py | 208 ++ .../auth/oauth_proxy/test_oauth_proxy.py | 36 + tests/server/auth/test_authorization.py | 481 +++- tests/server/auth/test_issuer_url_identity.py | 297 +++ tests/server/auth/test_oauth_mounting.py | 13 +- .../test_oauth_proxy_redirect_validation.py | 317 +++ tests/server/auth/test_oidc_proxy.py | 136 + .../server/auth/test_static_token_verifier.py | 2 +- tests/server/http/test_routable_headers.py | 89 + .../server/middleware/test_caching_guards.py | 123 + tests/server/middleware/test_middleware.py | 4 - .../middleware/test_middleware_nested.py | 10 +- .../openapi/test_openapi_discriminator.py | 286 ++ .../openapi/test_openapi_features.py | 115 + .../providers/proxy/test_proxy_client.py | 48 +- .../providers/proxy/test_proxy_server.py | 76 +- .../server/providers/test_prefab_roundtrip.py | 603 ++++- .../server/providers/test_skills_provider.py | 8 - tests/server/sampling/__init__.py | 0 tests/server/sampling/test_prepare_tools.py | 111 - tests/server/sampling/test_sampling_tool.py | 440 ---- .../server/telemetry/test_provider_tracing.py | 54 +- .../server/telemetry/test_sampling_tracing.py | 881 ------- tests/server/telemetry/test_server_tracing.py | 12 +- tests/server/test_context.py | 24 - tests/server/test_mrtr_guards_components.py | 230 ++ tests/server/test_protocol_eras.py | 294 +-- .../test_model_visibility_boundary.py | 168 ++ tests/tasks/client/test_client_tool_tasks.py | 4 +- tests/tasks/server/test_extension.py | 19 +- tests/tasks/server/test_guard_reentrant.py | 197 ++ tests/tasks/server/test_task_config.py | 2 +- tests/tasks/server/test_task_return_types.py | 2 +- tests/telemetry/test_interop.py | 280 ++ tests/test_exceptions.py | 57 + tests/test_fastmcp_app.py | 109 +- tests/test_upgrade_from_v3.py | 80 +- tests/tools/test_standalone_decorator.py | 2 +- tests/tools/tool/test_results.py | 14 +- tests/tools/tool_transform/test_metadata.py | 39 + tests/utilities/test_json_schema.py | 72 + tests/utilities/test_types.py | 19 +- uv.lock | 2296 +++++++++-------- 220 files changed, 12067 insertions(+), 8288 deletions(-) rename docs/development/v3-notes/auth-provider-env-vars.mdx => dev-docs/v3-notes/auth-provider-env-vars.md (100%) rename {v3-notes => dev-docs/v3-notes}/get-methods-consolidation.md (100%) rename {v3-notes => dev-docs/v3-notes}/prompt-internal-types.md (100%) rename {v3-notes => dev-docs/v3-notes}/provider-architecture.md (100%) rename {v3-notes => dev-docs/v3-notes}/provider-test-pattern.md (100%) rename {v3-notes => dev-docs/v3-notes}/resource-internal-types.md (100%) rename {v3-notes => dev-docs/v3-notes}/task-meta-parameter.md (100%) rename docs/development/v3-notes/v3-features.mdx => dev-docs/v3-notes/v3-features.md (98%) rename {v3-notes => dev-docs/v3-notes}/visibility.md (100%) rename docs/development/v4-notes/background-tasks.mdx => dev-docs/v4-notes/background-tasks.md (97%) rename docs/development/v4-notes/change-register.mdx => dev-docs/v4-notes/change-register.md (77%) rename docs/development/v4-notes/feature-program.mdx => dev-docs/v4-notes/feature-program.md (78%) rename docs/development/v4-notes/index.mdx => dev-docs/v4-notes/index.md (76%) rename docs/development/v4-notes/known-gaps.mdx => dev-docs/v4-notes/known-gaps.md (89%) rename docs/development/v4-notes/protocol-2026.mdx => dev-docs/v4-notes/protocol-2026.md (84%) rename {docs/development => dev-docs}/v4-notes/stateless-session-state.md (100%) rename docs/getting-started/upgrading/{from-low-level-sdk.mdx => from-low-level-sdk-v1.mdx} (61%) create mode 100644 docs/getting-started/upgrading/from-low-level-sdk-v2.mdx create mode 100644 docs/getting-started/upgrading/from-mcp-sdk-v1.mdx create mode 100644 docs/getting-started/upgrading/from-mcp-sdk-v2.mdx delete mode 100644 docs/getting-started/upgrading/from-mcp-sdk.mdx delete mode 100644 examples/sampling/README.md delete mode 100644 examples/sampling/server_fallback.py delete mode 100644 examples/sampling/structured_output.py delete mode 100644 examples/sampling/text.py delete mode 100644 examples/sampling/tool_use.py delete mode 100644 fastmcp_slim/fastmcp/experimental/sampling/__init__.py delete mode 100644 fastmcp_slim/fastmcp/experimental/sampling/handlers/__init__.py delete mode 100644 fastmcp_slim/fastmcp/experimental/sampling/handlers/openai.py delete mode 100644 fastmcp_slim/fastmcp/server/auth/authorization.py create mode 100644 fastmcp_slim/fastmcp/server/providers/prefab_payload.py delete mode 100644 fastmcp_slim/fastmcp/server/sampling/__init__.py delete mode 100644 fastmcp_slim/fastmcp/server/sampling/run.py delete mode 100644 fastmcp_slim/fastmcp/server/sampling/sampling_tool.py delete mode 100644 tests/client/test_sampling_result_types.py delete mode 100644 tests/client/test_sampling_tool_loop.py delete mode 100644 tests/deprecated/test_elicitation.py create mode 100644 tests/docs/test_upgrade_guide_api_claims.py create mode 100644 tests/docs/test_upgrade_guide_equivalence.py create mode 100644 tests/docs/test_upgrade_guide_examples.py create mode 100644 tests/server/auth/test_issuer_url_identity.py create mode 100644 tests/server/http/test_routable_headers.py create mode 100644 tests/server/middleware/test_caching_guards.py create mode 100644 tests/server/providers/openapi/test_openapi_discriminator.py delete mode 100644 tests/server/sampling/__init__.py delete mode 100644 tests/server/sampling/test_prepare_tools.py delete mode 100644 tests/server/sampling/test_sampling_tool.py delete mode 100644 tests/server/telemetry/test_sampling_tracing.py create mode 100644 tests/server/test_mrtr_guards_components.py create mode 100644 tests/server/transforms/test_model_visibility_boundary.py create mode 100644 tests/telemetry/test_interop.py diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index 93d0ebbf8..ae37ee33a 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -96,12 +96,9 @@ Codex sometimes re-posts old comments that reference code you've already fixed ( ## Labels — never apply or invent them -**Do not apply labels to PRs or issues programmatically, and never create new ones.** Labeling is the maintainer's call (and is often automated). Two hard rules: +**Do not apply labels to PRs or issues programmatically, and never create new ones.** Issues and PRs in this repo are auto-labeled by a bot based on title, body, and code changes — there's no fixed canonical list to match against, and GitHub's "add labels" API auto-creates any label name that doesn't already exist, so a typo or guessed name silently pollutes the repo's label list with a stray, uncolored duplicate. There is no MCP tool to delete a label, so a mistaken creation can only be cleaned up by hand in repo settings. -- **Never invent a label.** GitHub's "add labels" API *auto-creates* any label name that doesn't already exist — so a typo or a guessed name silently pollutes the repo's label list with a stray, uncolored duplicate. Adding `breaking` (which does not exist) creates it alongside the real `breaking change` label. -- **Use only labels that already exist.** If you genuinely need to confirm a label, look it up first (`get_label` / the repo's label list) and match the exact name. The canonical names here are specific — e.g. the breaking-change label is **`breaking change`**, not `breaking`; enhancements is **`enhancements`**, features is **`features`**, bugs is **`bugs`**. - -When a change warrants a label (e.g. it's breaking), **say so in the PR body and let the maintainer apply the label** rather than applying it yourself. There is no MCP tool to delete a label, so a mistaken creation can only be cleaned up by hand in repo settings — the cost of guessing is high and one-directional. +Don't call out a "suggested" or "appropriate" label in the PR body either — the bot doesn't read it, and it just adds noise. ## When a PR is ready diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml index c98bc1312..a9de7d0d3 100644 --- a/.github/workflows/marvin-dedupe-issues.yml +++ b/.github/workflows/marvin-dedupe-issues.yml @@ -19,6 +19,13 @@ jobs: issues: write id-token: write + # TEMPORARY PIN — see the matching note in marvin-label-triage.yml. + # Claude Code 2.1.216 broke every Bash call under the action's subprocess + # isolation, which this workflow needs for all of its `gh` searching. + # https://github.com/anthropics/claude-code/issues/79997 + env: + PINNED_CLAUDE_CODE_VERSION: "2.1.215" + steps: - name: Checkout repository uses: actions/checkout@v7 @@ -91,9 +98,17 @@ jobs: - name: Clean up stale Claude locks run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true + - name: Install pinned Claude Code + id: pin-claude + run: | + curl -fsSL https://claude.ai/install.sh | bash -s -- "$PINNED_CLAUDE_CODE_VERSION" + echo "path=$HOME/.local/bin/claude" >> "$GITHUB_OUTPUT" + "$HOME/.local/bin/claude" --version + - name: Run Marvin dedupe command uses: anthropics/claude-code-action@v1 with: + path_to_claude_code_executable: ${{ steps.pin-claude.outputs.path }} github_token: ${{ steps.marvin-token.outputs.token }} bot_name: "Marvin Context Protocol" prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }} diff --git a/.github/workflows/marvin-label-triage.yml b/.github/workflows/marvin-label-triage.yml index aef7d9e2a..2cd4fe7f5 100644 --- a/.github/workflows/marvin-label-triage.yml +++ b/.github/workflows/marvin-label-triage.yml @@ -27,6 +27,22 @@ jobs: issues: write pull-requests: write + # TEMPORARY PIN — remove once upstream ships a fix. + # + # Claude Code 2.1.216 regressed the sandbox that claude-code-action wraps + # every Bash call in when `allowed_non_write_users` is set: the mountpoint + # walk fails closed, so every command — down to `true` — dies with + # `bwrap: Can't create file at /home/.mcp.json: Permission denied`. + # Marvin still reads the issue and picks correct labels, then cannot run + # the helper that applies them, so triage silently applied zero labels + # from 2026-07-20 onward while every run reported success. + # + # 2.1.215 is the last release without the regression. + # https://github.com/anthropics/claude-code/issues/79997 + # https://github.com/anthropics/claude-code-action/issues/1547 + env: + PINNED_CLAUDE_CODE_VERSION: "2.1.215" + steps: - name: Checkout base repository uses: actions/checkout@v7 @@ -142,10 +158,21 @@ jobs: - name: Clean up stale Claude locks run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true + # Mirrors how the action installs Claude Code itself, minus the version + # it hardcodes. Passing path_to_claude_code_executable makes the action + # skip its own install and use this build. + - name: Install pinned Claude Code + id: pin-claude + run: | + curl -fsSL https://claude.ai/install.sh | bash -s -- "$PINNED_CLAUDE_CODE_VERSION" + echo "path=$HOME/.local/bin/claude" >> "$GITHUB_OUTPUT" + "$HOME/.local/bin/claude" --version + - name: Run Marvin for Issue Triage id: marvin uses: anthropics/claude-code-action@v1 with: + path_to_claude_code_executable: ${{ steps.pin-claude.outputs.path }} github_token: ${{ steps.marvin-token.outputs.token }} bot_name: "Marvin Context Protocol" prompt: ${{ steps.triage-prompt.outputs.PROMPT }} @@ -173,7 +200,7 @@ jobs: # agent reaching for something never on the allowlist (falling back to # `gh issue view` when the API is down, say) is behaving normally, and # failing on that would cry wolf during every GitHub incident. - - name: Fail if an allowlisted tool was denied + - name: Fail if Marvin could not run its tools if: always() && steps.marvin.conclusion != 'skipped' env: EXECUTION_FILE: ${{ steps.marvin.outputs.execution_file }} @@ -222,9 +249,41 @@ jobs: echo "::notice::Marvin was denied $total call(s), none of them to tools this workflow grants. That is expected when it probes for a tool we deliberately withhold; the allowlist is intact." fi + # A granted tool can also fail *after* the permission check, which the + # denial count above cannot see. Claude Code 2.1.216 did exactly that: + # the sandbox refused to build and every Bash call — including the + # labeling helper — exited 1 with `bwrap: ...`, while the run stayed + # green. Correlate results back to their Bash tool_use rather than + # grepping the whole log, so an issue body quoting a sandbox error + # cannot fail an otherwise healthy run. + if ! sandbox=$(jq -sr ' + [ .[] | if type == "array" then .[] else . end ] + | map(select(type == "object" and (.type == "assistant" or .type == "user"))) + | map(.message.content // []) | flatten + | map(select(type == "object")) + | . as $blocks + | ( $blocks + | map(select(.type == "tool_use" and .name == "Bash")) + | map(.id) ) as $bash + | $blocks + | map(select(.type == "tool_result" and (.tool_use_id as $i | $bash | index($i)))) + | map(.content | tostring) + | map(select(test("bwrap:|Failed to (start|create) sandbox"))) + | "\(length)\t\(.[0] // "" | gsub("[\t\n]"; " ") | .[0:200])" + ' "$file"); then + echo "::error::Could not scan Marvin execution log for sandbox failures ($file)." + exit 1 + fi + IFS=$'\t' read -r sandbox_failures sandbox_sample <<<"$sandbox" + + if [[ "$sandbox_failures" -gt 0 ]]; then + echo "::error::Marvin's Bash tool failed $sandbox_failures time(s) inside the action's subprocess sandbox, so it could not apply labels: ${sandbox_sample}. This is an environment failure, not a prompt or allowlist problem — check whether the pinned Claude Code version (${PINNED_CLAUDE_CODE_VERSION}) still avoids the upstream sandbox regression." + exit 1 + fi + - name: Upload Marvin execution log if: always() && steps.marvin.conclusion != 'skipped' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: marvin-triage-execution-log path: | diff --git a/.github/workflows/marvin-test-failure.yml b/.github/workflows/marvin-test-failure.yml index c21204815..c0c532b23 100644 --- a/.github/workflows/marvin-test-failure.yml +++ b/.github/workflows/marvin-test-failure.yml @@ -35,7 +35,7 @@ jobs: private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: Set up Python 3.10 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.10" diff --git a/.github/workflows/publish-fastmcp-tasks.yml b/.github/workflows/publish-fastmcp-tasks.yml index 9aa5326a7..29fc38554 100644 --- a/.github/workflows/publish-fastmcp-tasks.yml +++ b/.github/workflows/publish-fastmcp-tasks.yml @@ -23,13 +23,29 @@ jobs: fetch-depth: 0 ref: ${{ github.event.workflow_run.head_sha || github.sha }} + # Maintenance branches predate the standalone fastmcp-tasks package and + # resolve the `tasks` extra through fastmcp-slim instead. This workflow + # runs from the default branch for every fastmcp-slim release, including + # those tags, so detect the package rather than assume it is there. + - name: Check whether this ref builds fastmcp-tasks + id: package_present + run: | + if [ -d fastmcp_tasks ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "This ref has no fastmcp_tasks package; nothing to publish." + fi + - name: Install uv uses: astral-sh/setup-uv@v7 - name: Build fastmcp-tasks + if: steps.package_present.outputs.present == 'true' run: uv build --package fastmcp-tasks - name: Verify matching fastmcp-slim is published + if: steps.package_present.outputs.present == 'true' run: | SLIM_VERSION=$(python - <<'PY' import email.parser @@ -84,4 +100,5 @@ jobs: exit 1 - name: Publish fastmcp-tasks to PyPI + if: steps.package_present.outputs.present == 'true' run: uv publish -v dist/fastmcp_tasks-*.tar.gz dist/fastmcp_tasks-*.whl diff --git a/.github/workflows/publish-fastmcp.yml b/.github/workflows/publish-fastmcp.yml index 52b319d5c..dc32179cb 100644 --- a/.github/workflows/publish-fastmcp.yml +++ b/.github/workflows/publish-fastmcp.yml @@ -134,17 +134,24 @@ jobs: # fastmcp-tasks is pinned via the optional `tasks` extra, so its # Requires-Dist entry carries an `extra == "tasks"` marker — unlike the # base slim dependency, do not skip marked entries here. + # + # Print nothing when there is no such pin. Release lines that resolve + # the `tasks` extra through fastmcp-slim instead of a standalone + # fastmcp-tasks package have nothing here to verify. for value in metadata.get_all("Requires-Dist", []): requirement, _, _marker = value.partition(";") match = re.fullmatch(r"fastmcp-tasks==([^;\s]+)", requirement.strip()) if match: print(match.group(1)) break - else: - raise RuntimeError("Could not find the fastmcp-tasks extra dependency") PY ) + if [ -z "$TASKS_VERSION" ]; then + echo "This build does not pin fastmcp-tasks; the [tasks] extra cannot be uninstallable, so there is nothing to verify." + exit 0 + fi + for attempt in {1..12}; do if python - "$TASKS_VERSION" <<'PY' import json diff --git a/CLAUDE.md b/CLAUDE.md index e900cc9c7..e6357e06b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ When modifying MCP functionality, changes typically need to be applied across al - Prek hooks are required (run automatically on commits) - Never amend commits to fix prek failures -- Never apply labels manually or invent new ones — the GitHub API auto-creates any unknown label name, polluting the repo's label list. Note the appropriate label in the PR body and let the maintainer/automation apply it. Canonical names: `bugs`, `breaking change`, `enhancements`, `features` (it's `breaking change`, not `breaking`). See the review-pr skill. +- Never apply labels manually or invent new ones — issues and PRs are auto-labeled by a bot based on title/body/code changes. Don't note a "suggested" or "appropriate" label anywhere in the PR body either. See the review-pr skill. - Improvements = enhancements (not features) unless specified - **NEVER** force-push on collaborative repos - **ALWAYS** run prek before PRs @@ -68,6 +68,12 @@ When modifying MCP functionality, changes typically need to be applied across al - **NEVER** merge a PR marked as do-not-merge or draft. Check title, body, AND labels for `[DNM]`, `DNM`, `DO NOT MERGE`, `DON'T MERGE`, `DONT MERGE`, `do-not-merge`, `dont-merge`, `[DRAFT]`, or `DRAFT` (case-insensitive, any variation — some authors use `[DRAFT]` in the title even when `isDraft` is false). Authors use these as hard stops — respect them even if CI is green and review looks clean. When triaging a batch of PRs, filter these out up front AND re-check each one's labels immediately before merging, since labels can change mid-session. - **ALWAYS** read review-bot comments before approving a PR. CodeRabbit and chatgpt-codex-connector (Codex) leave substantive review comments on most PRs in this repo — these bots have read the diff and often flag real issues that aren't in the PR description. Use `gh pr view --comments` and read the bot feedback as part of review. Unlike proposed solutions from issue reporters, review-bot feedback should be evaluated on its merits, not discounted. - **Be constructively skeptical of bot review comments on your own PRs.** CodeRabbit, Codex, and claude[bot] run a fresh review pass on every push, which means a PR with active churn can accumulate bot comments in a stream that never really ends — each fix surfaces a new edge case the next pass can flag. Most of the early feedback is real and worth acting on; diminishing returns set in fast. Evaluate each comment on its merits, the same way you would a human reviewer: is this a real bug users will hit, or a hypothetical that requires an adversarial setup? Does the fix introduce more complexity than the problem? Has the bot missed context that's obvious to a human reader (a `*,` keyword-only marker, a design decision documented elsewhere, something already resolved on a later commit)? When a comment is pedantic, a false positive, or flagging something already fixed, reply on the thread explaining the reasoning and move on — don't keep iterating just because more comments arrive. If you find yourself three rounds deep and the feedback is shifting toward "what if someone does X" hypotheticals, you're past the point where each fix is improving the PR. Stop, document the contract as-is, and ship. +- **Resolve a review thread when you fix it; reply when you're declining it.** A fix explains itself through the commit, so resolving is enough — and it leaves unresolved threads meaning unfinished business, which is the signal worth having. A decline needs a one-line reason in a reply, because resolving collapses the thread and a hidden objection is worse than a visible one. Doing both is noise. Get thread ids from the GraphQL `reviewThreads` field, then resolve: + + ```bash + gh api graphql -f query='query($n:Int!){repository(owner:"PrefectHQ",name:"fastmcp"){pullRequest(number:$n){reviewThreads(first:50){nodes{id isResolved path}}}}}' -F n= + gh api graphql -f query='mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}' -F id=PRRT_... + ``` ### Outbound Comments and Shell Interpolation diff --git a/README.md b/README.md index b2b8c1886..e29ce7a9b 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,10 @@ uv pip install fastmcp For full installation instructions, including verification and upgrading, see the [**Installation Guide**](https://gofastmcp.com/getting-started/installation). **Upgrading?** We have guides for: -- [Upgrading from FastMCP v2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) -- [Upgrading from the MCP Python SDK](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk) -- [Upgrading from the low-level SDK](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk) +- [Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3) +- [Upgrading from FastMCP 2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) +- [Upgrading from MCP SDK v1](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2) +- [Upgrading from the low-level SDK v1](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2) > [!NOTE] > If `import fastmcp` fails right after a `pip` upgrade from FastMCP 3.2 or earlier, run `pip install --force-reinstall fastmcp`. See [Troubleshooting](https://gofastmcp.com/getting-started/installation#troubleshooting) for why this happens (`uv` is unaffected). diff --git a/docs/development/v3-notes/auth-provider-env-vars.mdx b/dev-docs/v3-notes/auth-provider-env-vars.md similarity index 100% rename from docs/development/v3-notes/auth-provider-env-vars.mdx rename to dev-docs/v3-notes/auth-provider-env-vars.md diff --git a/v3-notes/get-methods-consolidation.md b/dev-docs/v3-notes/get-methods-consolidation.md similarity index 100% rename from v3-notes/get-methods-consolidation.md rename to dev-docs/v3-notes/get-methods-consolidation.md diff --git a/v3-notes/prompt-internal-types.md b/dev-docs/v3-notes/prompt-internal-types.md similarity index 100% rename from v3-notes/prompt-internal-types.md rename to dev-docs/v3-notes/prompt-internal-types.md diff --git a/v3-notes/provider-architecture.md b/dev-docs/v3-notes/provider-architecture.md similarity index 100% rename from v3-notes/provider-architecture.md rename to dev-docs/v3-notes/provider-architecture.md diff --git a/v3-notes/provider-test-pattern.md b/dev-docs/v3-notes/provider-test-pattern.md similarity index 100% rename from v3-notes/provider-test-pattern.md rename to dev-docs/v3-notes/provider-test-pattern.md diff --git a/v3-notes/resource-internal-types.md b/dev-docs/v3-notes/resource-internal-types.md similarity index 100% rename from v3-notes/resource-internal-types.md rename to dev-docs/v3-notes/resource-internal-types.md diff --git a/v3-notes/task-meta-parameter.md b/dev-docs/v3-notes/task-meta-parameter.md similarity index 100% rename from v3-notes/task-meta-parameter.md rename to dev-docs/v3-notes/task-meta-parameter.md diff --git a/docs/development/v3-notes/v3-features.mdx b/dev-docs/v3-notes/v3-features.md similarity index 98% rename from docs/development/v3-notes/v3-features.mdx rename to dev-docs/v3-notes/v3-features.md index 32487890c..62d11e0b1 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/dev-docs/v3-notes/v3-features.md @@ -185,7 +185,7 @@ Key features: - Fuzzy tool name matching suggests alternatives on typos - Interactive terminal elicitation for tools that request user input mid-execution -Documentation: [CLI Querying](/cli/client) +Documentation: [CLI Querying](https://gofastmcp.com/v3/cli/client) ### CLI: `fastmcp discover` and name-based resolution @@ -207,7 +207,7 @@ fastmcp call cursor:weather get_forecast city=London fastmcp discover --source claude-code --source cursor ``` -Documentation: [CLI Querying](/cli/client) +Documentation: [CLI Querying](https://gofastmcp.com/v3/cli/client) ### CLI: Expanded Reload File Watching @@ -272,7 +272,7 @@ Key details: - Servers fetch and cache documents with standard HTTP caching (ETag, Last-Modified, Cache-Control) - CIMD is a protocol-level feature — any auth provider implementing the spec can support it -Documentation: [CIMD Authentication](/clients/auth/cimd), [OAuth Proxy CIMD config](/servers/auth/oauth-proxy#cimd-support) +Documentation: [CIMD Authentication](https://gofastmcp.com/v3/clients/auth/cimd), [OAuth Proxy CIMD config](https://gofastmcp.com/v3/servers/auth/oauth-proxy#cimd-support) ### Pre-Registered OAuth Clients @@ -295,7 +295,7 @@ async with Client( The static credentials are injected before the OAuth flow begins, so the client never attempts DCR. If the server rejects the credentials, the error surfaces immediately rather than retrying with fresh registration (which can't help for fixed credentials). Public clients can omit `client_secret`. -Documentation: [Pre-Registered Clients](/clients/auth/oauth#pre-registered-clients) +Documentation: [Pre-Registered Clients](https://gofastmcp.com/v3/clients/auth/oauth#pre-registered-clients) ### CLI: `fastmcp generate-cli` @@ -315,7 +315,7 @@ python my_weather_cli.py read-resource docs://readme The generated script embeds the resolved transport (URL or stdio command), so it's self-contained — users don't need to know about MCP or FastMCP to use it. Supports `-f` to overwrite existing files, and name-based resolution via `fastmcp discover`. -Documentation: [Generate CLI](/cli/generate-cli) +Documentation: [Generate CLI](https://gofastmcp.com/v3/cli/generate-cli) ### CLI: Goose Integration @@ -326,7 +326,7 @@ fastmcp install goose server.py fastmcp install goose server.py --with pandas --python 3.11 ``` -Also adds a full integration guide at [Goose Integration](/integrations/goose). +Also adds a full integration guide at [Goose Integration](https://gofastmcp.com/v3/integrations/goose). ### ResponseLimitingMiddleware @@ -352,7 +352,7 @@ Key features: - Size metadata added to result's `meta` field for monitoring - Configurable `raise_on_structured` and `raise_on_unstructured` behavior -Documentation: [Middleware](/servers/middleware) +Documentation: [Middleware](https://gofastmcp.com/v3/servers/middleware) ### Background Task Context (SEP-1686) @@ -1112,7 +1112,7 @@ Features: - **Package support**: Directories with `__init__.py` support relative imports - **Warning deduplication**: Broken imports warn once per file modification -Documentation: [FileSystemProvider](/servers/providers/filesystem) +Documentation: [FileSystemProvider](https://gofastmcp.com/v3/servers/providers/filesystem) --- @@ -1155,7 +1155,7 @@ Each subdirectory with a `SKILL.md` file becomes a discoverable skill. Clients s **Progressive disclosure**: By default, supporting files are hidden from `list_resources()` and accessed via template. Set `supporting_files="resources"` for full enumeration. -Documentation: [Skills Provider](/servers/providers/skills) +Documentation: [Skills Provider](https://gofastmcp.com/v3/servers/providers/skills) --- @@ -1183,7 +1183,7 @@ trace.set_tracer_provider(provider) Components provide their own span attributes through a `get_span_attributes()` method that subclasses override—this lets LocalProvider, FastMCPProvider, and ProxyProvider each include relevant context (original names, backend URIs, etc.). -Documentation: [Telemetry](/servers/telemetry) +Documentation: [Telemetry](https://gofastmcp.com/v3/servers/telemetry) --- @@ -1209,7 +1209,7 @@ async with Client(server) as client: result = await client.list_tools_mcp(cursor=result.next_cursor) ``` -Documentation: [Pagination](/servers/pagination) +Documentation: [Pagination](https://gofastmcp.com/v3/servers/pagination) --- @@ -1250,7 +1250,7 @@ from fastmcp.utilities.lifespan import combine_lifespans app = FastAPI(lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan)) ``` -Documentation: [Lifespan](/servers/lifespan) +Documentation: [Lifespan](https://gofastmcp.com/v3/servers/lifespan) --- @@ -1456,7 +1456,7 @@ auth = GitHubProvider( ) ``` -See `docs/development/v3-notes/auth-provider-env-vars.mdx` for rationale. +See `dev-docs/v3-notes/auth-provider-env-vars.md` for rationale. #### Server Banner Environment Variable diff --git a/v3-notes/visibility.md b/dev-docs/v3-notes/visibility.md similarity index 100% rename from v3-notes/visibility.md rename to dev-docs/v3-notes/visibility.md diff --git a/docs/development/v4-notes/background-tasks.mdx b/dev-docs/v4-notes/background-tasks.md similarity index 97% rename from docs/development/v4-notes/background-tasks.mdx rename to dev-docs/v4-notes/background-tasks.md index a342138af..125c8ba73 100644 --- a/docs/development/v4-notes/background-tasks.mdx +++ b/dev-docs/v4-notes/background-tasks.md @@ -2,7 +2,7 @@ title: Background Tasks (SEP-2663) --- -**Status: Shipped (#4602, #4603).** This page is the approved design for rebuilding FastMCP's background-task support on the `io.modelcontextprotocol/tasks` extension. It supersedes the earlier "delete the task machinery" direction recorded during the SDK v2 migration. The [Feature Program](/development/v4-notes/feature-program#background-tasks-sep-2663) carries the one-line status; user-facing usage is documented at [Background Tasks](/servers/tasks) and [Background Tasks (client)](/clients/tasks). +**Status: Shipped (#4602, #4603).** This page is the approved design for rebuilding FastMCP's background-task support on the `io.modelcontextprotocol/tasks` extension. It supersedes the earlier "delete the task machinery" direction recorded during the SDK v2 migration. The [Feature Program](feature-program.md#background-tasks-sep-2663) carries the one-line status; user-facing usage is documented at [Background Tasks](https://gofastmcp.com/servers/tasks) and [Background Tasks (client)](https://gofastmcp.com/clients/tasks). ## TL;DR @@ -107,7 +107,7 @@ async def crunch(dataset: str) -> str: The extension API contributes a negotiated capability, additive request methods, and a `tools/call` interceptor — with access to FastMCP-level constructs the SDK's `Extension` withholds (the component registry, `Context`, auth scope). It is **designed against tasks** because tasks exercises the full surface (capability + methods + interception + client claims + notifications), where Apps exercises only a subset. Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices and confirming the design generalizes. -**Extension vs. middleware** — the discriminator, so we do not over-apply this: an extension is a *negotiated contract change the client must understand*; middleware is *unilateral server behavior the client never sees*. PII detection, auth, rate limiting → [middleware](/servers/middleware). Tasks, Apps → extensions. Litmus test: delete the capability advertisement — if nothing about the client's behavior changes, it was middleware. +**Extension vs. middleware** — the discriminator, so we do not over-apply this: an extension is a *negotiated contract change the client must understand*; middleware is *unilateral server behavior the client never sees*. PII detection, auth, rate limiting → [middleware](https://gofastmcp.com/servers/middleware). Tasks, Apps → extensions. Litmus test: delete the capability advertisement — if nothing about the client's behavior changes, it was middleware. ### Client experience diff --git a/docs/development/v4-notes/change-register.mdx b/dev-docs/v4-notes/change-register.md similarity index 77% rename from docs/development/v4-notes/change-register.mdx rename to dev-docs/v4-notes/change-register.md index 457cebf30..eb1db6549 100644 --- a/docs/development/v4-notes/change-register.mdx +++ b/dev-docs/v4-notes/change-register.md @@ -4,28 +4,45 @@ title: Change Register This is the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), organized by subsystem. It doubles as a review lens: take one subsystem, read its claimed changes, and verify each against the diff. -Each entry is tagged **Absorbed** (public surface unchanged), **Bridged** (shim keeps old code working, usually warning), **Breaking** (user code must change), or **Deprecated** (works, warns, slated for removal). See the [overview](/development/v4-notes/index) for what each disposition means. +Each entry is tagged **Absorbed** (public surface unchanged), **Bridged** (shim keeps old code working, usually warning), **Breaking** (user code must change), or **Deprecated** (works, warns, slated for removal). See the [overview](index.md) for what each disposition means. -**Empirical validation (WS2 upgrade reality-check).** The register's compatibility claims are verified, not predicted. Running unchanged 3.x-era code against this branch, all 11 upgrade scenarios pass or warn — the only failures are the two predicted breaks, user `mcp.types` imports and positional `McpError(ErrorData(...))` construction. Cross-version wire interop between a 3.4.3 peer and this branch is bidirectionally clean across 9 operations (3.4.3 client ↔ v4 server and v4 client ↔ 3.4.3 server over HTTP). All 29 `_ALIASES` bridge entries warn correctly with actionable messages. +**Empirical validation (WS2 upgrade reality-check).** The register's compatibility claims are verified, not predicted. Running unchanged 3.x-era code against this branch, all 11 upgrade scenarios pass or warn — the only failures were the two predicted breaks, user `mcp.types` imports and positional `McpError(ErrorData(...))` construction — and the first of those went away when the stable SDK restored `mcp.types` (below). Cross-version wire interop between a 3.4.3 peer and this branch is bidirectionally clean across 9 operations (3.4.3 client ↔ v4 server and v4 client ↔ 3.4.3 server over HTTP). All 29 `_ALIASES` bridge entries warn correctly with actionable messages. ## Environment ### Dependency floors: pydantic >= 2.12, Starlette >= 1.0 — Breaking (environment) -The SDK v2 raises FastMCP's dependency floors. Projects pinning an older pydantic (e.g. `2.11.*`) hit an unsatisfiable-resolution error at install time and must bump their pin; unpinned projects get pydantic upgraded silently. The server extra floors Starlette at `>=1.0.1` — modern FastAPI (0.11x+) already runs Starlette 1.x, so coexistence is clean (verified with FastAPI 0.138.2); only very old FastAPI pinned below Starlette 1.0 conflicts. Both are documented in the [upgrade guide's Environment requirements](/getting-started/upgrading/from-fastmcp-3#environment-requirements). +The SDK v2 raises FastMCP's dependency floors. Projects pinning an older pydantic (e.g. `2.11.*`) hit an unsatisfiable-resolution error at install time and must bump their pin; unpinned projects get pydantic upgraded silently. The server extra floors Starlette at `>=1.0.1` — modern FastAPI (0.11x+) already runs Starlette 1.x, so coexistence is clean (verified with FastAPI 0.138.2); only very old FastAPI pinned below Starlette 1.0 conflicts. Both are documented in the [upgrade guide's Environment requirements](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3#environment-requirements). *Verify:* `fastmcp_slim/pyproject.toml` (`pydantic[email]>=2.12.0` core, `starlette>=1.0.1` server extra); WS2 environment-upgrade scenario. ## Types and imports -The SDK v2 split protocol types into a standalone `mcp_types` package and renamed every field from camelCase to snake_case. This is the single largest source of user-facing change, and FastMCP absorbs nearly all of it. +The SDK v2 moved protocol types into a standalone `mcp_types` package — still importable as `mcp.types` — and renamed every model field from camelCase to snake_case in Python. The wire format is unchanged: the models keep their camelCase aliases and the SDK serializes with `by_alias=True`, so this renames the attributes code reads, not the JSON on the connection. This is the single largest source of user-facing change, and FastMCP absorbs nearly all of it. ### `mcp.types` split into `mcp_types` — Breaking (by omission) + +Superseded by the stable SDK — see "`mcp.types` restored as a permanent alias" below. The betas this section was written against had no `mcp.types`; `2.0.0` brought it back, so the break never reached a release. + + The `mcp.types` module no longer exists. Any `from mcp.types import X` or `import mcp.types` in user code raises `ImportError`. This is the one import change users cannot avoid. *Verify:* `fastmcp_slim/fastmcp/types.py`, and grep the diff for the doc migration `from mcp.types import` → `from fastmcp.types import` (30 sites). +### `mcp.types` restored as a permanent alias — Absorbed (stable-SDK change) + +The SDK betas removed `mcp.types` outright, which made user imports the one unavoidable break in the migration. SDK `2.0.0` reintroduced it as a permanent alias for `mcp_types`: a wildcard mirror where every name is the *same object* (`mcp.types.Tool is mcp_types.Tool`), with matching `__all__` and the same snake_case fields. It is not a v1 restoration — only the import path came back. So `from mcp.types import X` keeps working, and the break is gone. + +This leaves the two spellings pointing at one package, and FastMCP uses each in a different place on purpose: + +- **User-facing docs and examples use `mcp.types`.** Anyone installing `fastmcp` gets the full SDK (`fastmcp` → `fastmcp-slim[client,server]` → `[mcp]` → `mcp`), so the aliased path always resolves and is the spelling the SDK prefers. It also means a user's own dependency list needs only `mcp`, without naming `mcp-types` to satisfy a linter. +- **FastMCP's own source uses `mcp_types`.** `mcp.types` is a submodule of `mcp`, so importing it requires the whole SDK. `mcp-types` is a *core* `fastmcp-slim` dependency while `mcp` sits behind the `[mcp]` extra, and a bare `fastmcp-slim` install must import without the SDK present — a guarantee `test_bare_slim_import_needs_only_mcp_types` pins. Reaching for `mcp.types` in core modules (`exceptions.py`, `_compat.py`, `tools/`, `resources/`) would pull the full SDK into the slim floor and break it. + +The rule of thumb: import `mcp_types` in library code, write `mcp.types` in anything a user copies. Both resolve to the same objects, so neither choice constrains the other. + +*Verify:* `.venv/.../mcp/types/__init__.py` (the wildcard mirror), `fastmcp_slim/pyproject.toml` (`mcp-types` core vs `mcp` in the `[mcp]` extra), `tests/client/test_slim_package_boundaries.py::test_bare_slim_import_needs_only_mcp_types`, and `tests/test_upgrade_from_v3.py::TestRemovedSurfacesFailLoudly::test_mcp_types_import_path_restored_by_stable_sdk`. + ### `fastmcp.types` is the stable home — Bridged @@ -82,7 +99,7 @@ import fastmcp fastmcp.settings.mcp_camelcase_compat = False # now takes effect immediately ``` -The setting is documented in [Settings](/more/settings) as `FASTMCP_MCP_CAMELCASE_COMPAT`. +The setting is documented in [Settings](https://gofastmcp.com/more/settings) as `FASTMCP_MCP_CAMELCASE_COMPAT`. *Verify:* `fastmcp_slim/fastmcp/settings.py` (setting), `fastmcp_slim/fastmcp/_compat.py` (per-read gate), commit `d9659453`. @@ -142,7 +159,7 @@ Server-side middleware is a new first-class SDK concept: `Server.middleware` is ### Middleware observes every inbound message — New (coverage) -FastMCP's `Middleware` chain used to begin *inside* the per-method handlers, so `on_message`/`on_request`/`on_notification` only fired for messages that reached a tool/resource/prompt handler. Notifications, cancellations, and malformed or unroutable requests were invisible to middleware. `FastMCPServerMiddleware` — FastMCP's entry in the SDK's own middleware list — is now the dispatch root: it runs the `on_message`/`on_request`/`on_notification` pass for every message the interior handlers do not dispatch (all notifications including `notifications/cancelled`, `ping`, `logging/setLevel`, unknown methods, and component requests that fail validation before the handler runs). The component methods keep their interior dispatch unchanged, so `on_call_tool` and friends still receive the typed component result and a tool exception still propagates through `on_message`/`on_request` exactly where the built-in error/logging/timing middleware expect it — each hook fires exactly once per message. Multi-round (SEP-2322) calls compose cleanly with this: each round is a complete request→response cycle through the full chain, and an asking round's `call_next` returns the ask as an ordinary `InputRequiredToolResult` value (see the MRTR entry below). All thirteen built-in middleware pass their suites unmodified. See [What middleware sees](/servers/middleware#what-middleware-sees). +FastMCP's `Middleware` chain used to begin *inside* the per-method handlers, so `on_message`/`on_request`/`on_notification` only fired for messages that reached a tool/resource/prompt handler. Notifications, cancellations, and malformed or unroutable requests were invisible to middleware. `FastMCPServerMiddleware` — FastMCP's entry in the SDK's own middleware list — is now the dispatch root: it runs the `on_message`/`on_request`/`on_notification` pass for every message the interior handlers do not dispatch (all notifications including `notifications/cancelled`, `ping`, `logging/setLevel`, unknown methods, and component requests that fail validation before the handler runs). The component methods keep their interior dispatch unchanged, so `on_call_tool` and friends still receive the typed component result and a tool exception still propagates through `on_message`/`on_request` exactly where the built-in error/logging/timing middleware expect it — each hook fires exactly once per message. Multi-round (SEP-2322) calls compose cleanly with this: each round is a complete request→response cycle through the full chain, and an asking round's `call_next` returns the ask as an ordinary `InputRequiredToolResult` value (see the MRTR entry below). All thirteen built-in middleware pass their suites unmodified. See [What middleware sees](https://gofastmcp.com/servers/middleware#what-middleware-sees). *Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (`FastMCPServerMiddleware` root dispatch, `_INTERIOR_METHODS`), `fastmcp_slim/fastmcp/server/middleware/middleware.py` (`MiddlewarePhase`, `mark_interior_dispatched`), `fastmcp_slim/fastmcp/server/server.py` (`_dispatch_component_middleware`), `tests/server/middleware/test_message_visibility.py`. @@ -162,11 +179,11 @@ SDK v2 declares `extensions` as a real field on `ClientCapabilities`, so a clien The SEP-1686 task CRUD protocol (`tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`) is entirely FastMCP-owned — the SDK ships no task store. Task detection moves to a params field: `params.task is not None` on `CallToolRequestParams`, with `ttl` from `params.task.ttl`. The four task handlers port to `add_request_handler`. -The SDK has a real gap here (see [Known Gaps](/development/v4-notes/known-gaps) and sdk-feedback #1): it ships the task result types but omits them from the method registries, so a background-task `tools/call` returning a `CreateTaskResult` fails validation. FastMCP installs a registry-widening shim in `_sdk_patches.py` that adds `CreateTaskResult` to the `tools/call` result union and registers the `tasks/*` rows. It is a temporary patch with a self-documented removal trigger. +The SDK has a real gap here (see [Known Gaps](known-gaps.md) and sdk-feedback #1): it ships the task result types but omits them from the method registries, so a background-task `tools/call` returning a `CreateTaskResult` fails validation. FastMCP installs a registry-widening shim in `_sdk_patches.py` that adds `CreateTaskResult` to the `tools/call` result union and registers the `tasks/*` rows. It is a temporary patch with a self-documented removal trigger. Resources and prompts have **no `task` field** on their params in b1, so task-augmented resource reads and prompt gets are not wire-expressible — a documented capability regression, tracked by xfails, not a bug FastMCP fixes. -This section records the migration's *handling* of the SEP-1686 wire layer as it stood at merge. That layer is not the end state: it is slated for removal and rebuild on the `io.modelcontextprotocol/tasks` extension (SEP-2663) as the `fastmcp-tasks` package. See [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) for the forward plan; the `_sdk_patches.py` shim and the `server/tasks/*` wire handlers described here go away with it, while the Docket execution engine moves into `fastmcp-tasks`. +This section records the migration's *handling* of the SEP-1686 wire layer as it stood at merge. That layer is not the end state: it is slated for removal and rebuild on the `io.modelcontextprotocol/tasks` extension (SEP-2663) as the `fastmcp-tasks` package. See [Background Tasks (SEP-2663)](background-tasks.md) for the forward plan; the `_sdk_patches.py` shim and the `server/tasks/*` wire handlers described here go away with it, while the Docket execution engine moves into `fastmcp-tasks`. *Verify:* `fastmcp_slim/fastmcp/_sdk_patches.py`, `server/tasks/*`. @@ -176,11 +193,13 @@ SDK v2 seeds an `OpenTelemetryMiddleware` into every lowlevel `Server`, so each *Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (the `OpenTelemetryMiddleware` filter); `tests/server/telemetry/test_server_tracing.py::TestSingleServerSpan`. -### Telemetry on by default, with an explicit off-switch — Absorbed +### Telemetry on by default, with a three-way mode setting — Absorbed -FastMCP's OpenTelemetry instrumentation is on by default. Because FastMCP uses only the OpenTelemetry API, span creation is a no-op with negligible overhead (the API's `NonRecordingSpan`) unless the user configures an SDK and exporter — so being always-on costs nothing until you opt into collection. The new `FASTMCP_ENABLE_TELEMETRY` setting (`fastmcp.settings.enable_telemetry`, default `true`) is the explicit off-switch: set it to `false` and `get_tracer()` returns a genuine no-op tracer, so no FastMCP spans are created even when an SDK is configured. The off-switch governs FastMCP's own spans (all SERVER spans, plus FastMCP's high-level CLIENT span); the SDK's low-level `mcp-python-sdk` `MCP send ` CLIENT spans are governed by the user's OpenTelemetry SDK, not this setting. FastMCP's SERVER span now also carries `mcp.protocol.version` — the attribute the dropped SDK `OpenTelemetryMiddleware` set — restoring parity with the SDK's semantic conventions. +FastMCP's OpenTelemetry instrumentation is on by default. Because FastMCP uses only the OpenTelemetry API, span creation is a no-op with negligible overhead (the API's `NonRecordingSpan`) unless the user configures an SDK and exporter — so being always-on costs nothing until you opt into collection. `FASTMCP_TELEMETRY_MODE` (`fastmcp.settings.telemetry_mode`, default `native`) controls how much is active: `native` emits spans and propagates trace context; `propagation_only` emits no FastMCP spans but still extracts the incoming `_meta` context and attaches it, so downstream spans are parented to the calling trace; `off` is a full pass-through that touches neither spans nor context. The setting governs FastMCP's own spans (all SERVER spans, plus FastMCP's high-level CLIENT span); the SDK's low-level `mcp-python-sdk` `MCP send ` CLIENT spans are governed by the user's OpenTelemetry SDK, not this setting. `suppress_fastmcp_telemetry()` applies `propagation_only` semantics to a single block for library authors who own the MCP hierarchy for one operation rather than process-wide; it cannot override `off`. FastMCP's SERVER span now also carries `mcp.protocol.version` — the attribute the dropped SDK `OpenTelemetryMiddleware` set — restoring parity with the SDK's semantic conventions. -*Verify:* `fastmcp_slim/fastmcp/settings.py` (`enable_telemetry`); `fastmcp_slim/fastmcp/telemetry.py` (`get_tracer` off-switch); `fastmcp_slim/fastmcp/server/telemetry.py` (`get_protocol_span_attributes`); `tests/server/telemetry/test_server_tracing.py::TestTelemetryEnabledByDefault`, `::TestProtocolVersionAttribute`. +`propagation_only` is applied at the seam span, which is where the incoming `_meta` parent context is established for the whole request; suppressing only the deeper `server_span` would leave the per-request SERVER span intact and defeat the mode. + +*Verify:* `fastmcp_slim/fastmcp/settings.py` (`telemetry_mode`); `fastmcp_slim/fastmcp/telemetry.py` (`telemetry_mode`, `get_tracer`, `suppress_fastmcp_telemetry`); `fastmcp_slim/fastmcp/server/telemetry.py` (`_propagation_only_span`, `seam_span`, `get_protocol_span_attributes`); `tests/server/telemetry/test_server_tracing.py::TestTelemetryEnabledByDefault`, `::TestProtocolVersionAttribute`; `tests/telemetry/test_interop.py`. ### Spec-correct error codes via a central translator — Breaking (wire error code) @@ -288,7 +307,7 @@ client = Client("my_mcp_server.py", timeout=30.0) # also works ### Connection settings passed to `connect_session` — Breaking (custom transports) -`ClientTransport.connect_session` takes a new keyword-only `transport_options: TransportOptions | None`, describing how the connecting client wants its session built: which `ClientSession` class to instantiate, and whether to forward the caller's authorization header upstream. Proxies use it to relay backend results without enforcing their output schema (see [Proxy Servers](/servers/providers/proxy#tool-results-are-relayed-not-inspected)). +`ClientTransport.connect_session` takes a new keyword-only `transport_options: TransportOptions | None`, describing how the connecting client wants its session built: which `ClientSession` class to instantiate, and whether to forward the caller's authorization header upstream. Proxies use it to relay backend results without enforcing their output schema (see [Proxy Servers](https://gofastmcp.com/servers/providers/proxy#tool-results-are-relayed-not-inspected)). These settings previously lived on the transport instance, so a transport shared between clients leaked one client's configuration into another — including credential forwarding, which `create_proxy(some_client)` would silently enable on the caller's own client. They now travel with the client that wants them, and `forward_incoming_headers` is no longer a settable transport attribute. @@ -379,7 +398,7 @@ async with Client("https://example.com/mcp", auth=auth) as client: ## HTTP -The maintainer asked whether FastMCP can now delete its custom HTTP app and let the SDK's `Server.streamable_http_app()` handle everything. The answer for this PR is **no** — every override earns its keep. Convergence is a v4 project gated on three upstream additions (see [Feature Program](/development/v4-notes/feature-program)). +The maintainer asked whether FastMCP can now delete its custom HTTP app and let the SDK's `Server.streamable_http_app()` handle everything. The answer for this PR is **no** — every override earns its keep. Convergence is a v4 project gated on three upstream additions (see [Feature Program](feature-program.md)). ### Kept overrides — Absorbed @@ -431,32 +450,45 @@ The push-style Context features that require the server to call back into the cl | --- | --- | --- | | `ctx.info` / logging notifications | Supported | Supported | | Tools, resources, prompts, completions | Supported | Supported | -| `ctx.elicit` (imperative) | Supported | Not on the back-channel — use [elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol) | -| `ctx.sample` / `ctx.sample_step` | Supported (deprecated) | Removed — call an LLM server-side | -| `ctx.list_roots` | Supported | Via the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) | +| `ctx.elicit` (imperative) | Supported | Not on the back-channel — use [elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) | +| `ctx.sample` / `ctx.sample_step` | Not in the API | Not in the API — call an LLM server-side | +| `ctx.list_roots` | Not in the API | Not in the API — take paths as arguments, or use the [guard pattern](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) | +| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` is absent from the era's registry | | Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension | -Tools that rely on `ctx.elicit` or `ctx.list_roots` continue to work against clients on the session-based eras. On the modern era, elicitation is reachable through the multi-round "guard" pattern instead (a tool returns an `InputRequiredResult`; see the New entry below). Sampling is the exception: it is deprecated on every era and will not return on modern connections (see the Deprecated entry below). +Tools that rely on `ctx.elicit` continue to work against clients on the session-based eras; on the modern era, elicitation is reachable through the multi-round "guard" pattern instead (a tool returns an `InputRequiredResult`; see the New entry below). Sampling and roots have no era row to speak of — they left the server API entirely (see the Removed entry below). -Ordinary `ctx.info` usage emits an SDK-level `MCPDeprecationWarning` ("The logging capability is deprecated as of 2026-07-28 (SEP-2577)"). That warning comes from the SDK, not FastMCP, and is benign — logging keeps working on session-based connections per the matrix. `ctx.sample`/`ctx.sample_step` additionally emit a FastMCP-owned `FastMCPDeprecationWarning` (see below). The upgrade guide calls both out explicitly. +Ordinary `ctx.info` usage emits an SDK-level `MCPDeprecationWarning` ("The logging capability is deprecated as of 2026-07-28 (SEP-2577)"). That warning comes from the SDK, not FastMCP, and is benign — logging *notifications* ride the request's own stream and work on every era, including the modern one. The upgrade guide calls it out explicitly. Wire interop across the transition is verified: a 3.4.3 client against a v4 server and a v4 client against a 3.4.3 server are bidirectionally clean across 9 operations over HTTP (WS2). *Verify:* `docs/getting-started/upgrading/from-fastmcp-3.mdx` (the published matrix and SDK-warning note), `tests/server/test_protocol_eras.py`. -### Sampling deprecated, era-gated — Deprecated +### Server-initiated sampling and roots removed from the server API — Breaking -`ctx.sample()` and `ctx.sample_step()` are deprecated and slated for removal in a future FastMCP release. Server-initiated sampling relies on the `createMessage` back-channel that SEP-2577 removed from the wire as of `2026-07-28`, and unlike elicitation it has no multi-round-trip replacement (the agentic loop would exhaust the round-trip budget). Both methods now emit a `FastMCPDeprecationWarning` once per process (gated on `settings.deprecation_warnings`), and on a `2026-07-28` connection they raise a clear `ToolError` before touching the wire. The client-side sampling handler infrastructure (anthropic/openai/google_genai) is retained for future MRTR work and is not deprecated. The migration is to call an LLM directly from your server rather than borrowing the client's model. +FastMCP 4 is a modern MCP toolkit, so the capabilities the modern protocol removed are not in its server-authoring API. `Context.sample()`, `Context.sample_step()`, and `Context.list_roots()` are gone, along with the whole `fastmcp/server/sampling/` package (`SamplingTool`, `SampleStep`, `SamplingResult`, the tool loop, structured-result sampling) and the server-side handler arguments `FastMCP(sampling_handler=..., sampling_handler_behavior=...)`. These were previously deprecated-and-era-gated; they are now absent. Calling them raises `AttributeError`; the constructor kwargs raise a `TypeError` naming SEP-2577 and the migration. -The dead TODO at `server/context.py` (a background-task sampling relay that was never built) is removed: that relay is not being built, so the note is gone rather than left as a promise. +The motivating failure is that the gate had become the default experience. `Client` now defaults to `mode="auto"`, which negotiates `2026-07-28` against a FastMCP server, so an unmodified `ctx.sample()` server failed on an ordinary client connection. Four shipped examples (`examples/sampling/`) were broken by that flip; they are deleted rather than ported, and remain available on `release/3.x`. -*Verify:* `fastmcp_slim/fastmcp/server/context.py` (`_warn_sampling_deprecated`, `_is_modern_protocol`, the `sample`/`sample_step` gates), `docs/servers/sampling.mdx` (deprecation banner), `tests/server/test_protocol_eras.py` (warning + era-gate tests). +Server-initiated sampling and roots are *requests* — the server sends one and blocks for the answer — which needs a back-channel the sessionless protocol does not have. What the protocol removed is the *pushing*, not the asking: both capabilities remain reachable through the guard pattern, where a tool returns an `InputRequiredResult` whose `input_requests` map carries a `CreateMessageRequest` or a `ListRootsRequest`, the client answers it, and the tool re-runs and reads `ctx.input_responses`. `Client._drive_input_required()` dispatches those to the same `sampling_handler` / `roots` handler a handshake-era server would have pushed to, and `tests/conformance/server.py` exercises both routes. For roots that guard round is the recommended modern path. For generation it is available but usually the wrong tool — each round is a full request-response cycle, so an agentic loop exhausts the round-trip budget — and the recommended migration stays a direct LLM call from the server. + +**What is deliberately kept.** Client-side `Client(sampling_handler=..., roots=...)` and the provider handlers (anthropic/openai/google_genai) stay: a FastMCP client must still answer a legacy server's requests, and removing them would break interop with older servers. `docs/clients/sampling.mdx` and `docs/clients/roots.mdx` stay as real documentation. Logging is untouched — `ctx.log`/`info`/`debug`/`warning`/`error` are notifications that ride the request's own stream and work on every era. + +**Proxy relay.** `ProxyClient`'s default `roots` and `sampling_handler` are client-side handlers that relay a handshake-era backend's requests to the proxy's own front client. They are kept, because a proxy is a client to its backend and falls squarely under the interop guarantee above. They no longer route through the removed `Context` methods: both now call the SDK session directly (`ctx.session.list_roots()` / `ctx.session.create_message()`), an internal path with no public authoring surface. The relay is reachable only when both legs speak the handshake era. + +*Verify:* `fastmcp_slim/fastmcp/server/context.py` (no `sample`/`sample_step`/`list_roots`), `fastmcp_slim/fastmcp/server/server.py` (`_REMOVED_KWARGS`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`default_proxy_roots_handler`, `default_proxy_sampling_handler`), `docs/servers/sampling.mdx` (rewritten in place as the explainer), `tests/server/test_protocol_eras.py` (`test_removed_server_initiated_methods_are_absent`), `tests/server/providers/proxy/test_proxy_client.py` (relay still green). + +### `client.set_logging_level()` era-gated — Breaking (modern era) + +`logging/setLevel` asks a server to remember a level for the rest of the session, and it is absent from the `2026-07-28` method registry because that era has no session to remember it in. It previously surfaced the SDK's opaque "Method not found". `Client.set_logging_level()` now raises a `RuntimeError` naming the era and pointing at level-filtering in the client's `log_handler`; it is unchanged on handshake-era connections. It is never a silent no-op. + +*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`set_logging_level`), `tests/server/test_protocol_eras.py` (`test_set_logging_level_is_era_gated_on_modern`). ### Push-feature degradation quality — Resolved (was sdk-feedback #10) -On a `2026-07-28` connection the degradation error used to differ by feature: `ctx.list_roots` raised a clear `NoBackChannelError`, while `ctx.elicit` / `ctx.sample` surfaced a bare "Method not found" because those methods attach a `related_request_id` and reach client dispatch before failing. FastMCP now era-gates `ctx.elicit` and `ctx.sample`/`ctx.sample_step` to raise a clear, era-aware `ToolError` before the wire ("server-initiated sampling is not available on MCP 2026-07-28 connections…" and "elicitation via server-initiated requests is unavailable on 2026-07-28 connections."). The strict xfail that captured #10 is flipped to a passing test. +On a `2026-07-28` connection `ctx.elicit` used to surface a bare "Method not found", because it attaches a `related_request_id` and reaches client dispatch before failing. FastMCP now era-gates `ctx.elicit` to raise a clear, era-aware `ToolError` before the wire ("elicitation via server-initiated requests is unavailable on 2026-07-28 connections."). The strict xfail that captured #10 is flipped to a passing test. The sampling half of #10 is moot: `ctx.sample` no longer exists. -*Verify:* `tests/server/test_protocol_eras.py` (`test_elicit_sample_degradation_message_is_clear_on_modern`, now a real test), `server/context.py` (era gates). +*Verify:* `tests/server/test_protocol_eras.py` (`test_elicit_degradation_message_is_clear_on_modern`, now a real test), `server/context.py` (era gate). ### Server-level cache hints (SEP-2549) — New (opt-in feature) @@ -466,7 +498,7 @@ A FastMCP server can emit SEP-2549 freshness hints so a caching client (`fastmcp ### Elicitation on the modern protocol (SEP-2322), guard form — New (opt-in feature) -A tool can gather client input across rounds on a `2026-07-28` call by returning an `InputRequiredResult` (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle: the tool re-runs per round and reads the client's answers off two new `Context` properties, `ctx.input_responses` (`None` on the first round) and `ctx.request_state` (the echoed opaque state) — thin passthroughs matching the SDK's mcpserver semantics. This is the modern-era elicitation path the earlier per-feature matrix flagged as "MRTR rewrite pending"; it mirrors the SDK's base guard model exactly (tool re-runs, checks whether answers are present, returns to ask for more), with no FastMCP-invented resolver or annotation layer. For authoring these requests, `InputRequiredResult`, `ElicitRequest`, and `ElicitRequestFormParams` import from `mcp_types`. The `request_state` channel is sealed by the framework, not the author: FastMCP installs the SDK's `RequestStateBoundary` middleware on its low-level server, which seals every outgoing `request_state` and unseals and verifies every inbound echo before a tool runs — so a tool only ever sees plaintext and a tampered, expired, or foreign token is rejected with a frozen wire error. `FastMCP(request_state_security=RequestStateSecurity(keys=[...]))` supplies shared keys for multi-replica deployments; omitted, each process seals under an ephemeral key (correct single-process). Returning this result on a handshake-era (≤ 2025-11-25) connection raises a clear era error naming the mismatch rather than failing as a generic invalid result. The client half (`fastmcp.Client` at `mode="auto"`) drives the loop through its existing elicitation/sampling/roots handlers, capped by `input_required_max_rounds`. +A tool can gather client input across rounds on a `2026-07-28` call by returning an `InputRequiredResult` (see [Elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle: the tool re-runs per round and reads the client's answers off two new `Context` properties, `ctx.input_responses` (`None` on the first round) and `ctx.request_state` (the echoed opaque state) — thin passthroughs matching the SDK's mcpserver semantics. This is the modern-era elicitation path the earlier per-feature matrix flagged as "MRTR rewrite pending"; it mirrors the SDK's base guard model exactly (tool re-runs, checks whether answers are present, returns to ask for more), with no FastMCP-invented resolver or annotation layer. For authoring these requests, `InputRequiredResult`, `ElicitRequest`, and `ElicitRequestFormParams` import from `mcp_types`. The `request_state` channel is sealed by the framework, not the author: FastMCP installs the SDK's `RequestStateBoundary` middleware on its low-level server, which seals every outgoing `request_state` and unseals and verifies every inbound echo before a tool runs — so a tool only ever sees plaintext and a tampered, expired, or foreign token is rejected with a frozen wire error. `FastMCP(request_state_security=RequestStateSecurity(keys=[...]))` supplies shared keys for multi-replica deployments; omitted, each process seals under an ephemeral key (correct single-process). Returning this result on a handshake-era (≤ 2025-11-25) connection raises a clear era error naming the mismatch rather than failing as a generic invalid result. The client half (`fastmcp.Client` at `mode="auto"`) drives the loop through its existing elicitation/sampling/roots handlers, capped by `input_required_max_rounds`. *Verify:* `fastmcp_slim/fastmcp/server/context.py` (`input_responses`/`request_state` properties), `fastmcp_slim/fastmcp/server/low_level.py` (`RequestStateBoundary` install), `fastmcp_slim/fastmcp/server/server.py` (`request_state_security` param), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_call_tool` input-required passthrough + era gate), `fastmcp_slim/fastmcp/tools/base.py` (`InputRequiredToolResult`), `tests/server/test_mrtr_guards.py`. @@ -496,7 +528,7 @@ A proxy is a server on its front and a client on its back, and the two eras have ### The xfail register — Known gap -Roughly forty `xfail` markers across the test tree (concentrated in `tests/server/tasks/`, `tests/client/tasks/`, and `test_protocol_eras.py`) are the built-in beta tracker: each names the SDK gap it waits on. They are enumerated and mapped to sdk-feedback findings on the [Known Gaps](/development/v4-notes/known-gaps) page. +Roughly forty `xfail` markers across the test tree (concentrated in `tests/server/tasks/`, `tests/client/tasks/`, and `test_protocol_eras.py`) are the built-in beta tracker: each names the SDK gap it waits on. They are enumerated and mapped to sdk-feedback findings on the [Known Gaps](known-gaps.md) page. ## Security @@ -510,7 +542,7 @@ FastMCP keeps its own DCR redirect-URI hardening (PRs #4419, #4408) regardless o ### Identity assertion (SEP-990 ID-JAG) — Added (beta) -`OAuthProxy` (and `OIDCProxy`, which inherits it) accepts an optional `identity_assertion=IdentityAssertion(trusted_issuers=[...])`. When configured, the token endpoint accepts the RFC 7523 `urn:ietf:params:oauth:grant-type:jwt-bearer` grant carrying an enterprise IdP-issued ID-JAG, validates it (signature against the trusted issuer's JWKS, `iss`/`aud`/`exp`, `typ` of `oauth-id-jag+jwt`, mandatory `sub`, signed `client_id`/`resource` binding, and `jti` replay rejection), and mints a short-lived FastMCP access token carrying the asserted subject with no refresh token. Authorization server metadata advertises the `jwt-bearer` grant type and the `urn:ietf:params:oauth:grant-profile:id-jag` profile when enabled. This is server-side only; the client-side wrapper ships separately. See [Identity Assertion](/servers/auth/oauth-proxy#identity-assertion-sep-990). +`OAuthProxy` (and `OIDCProxy`, which inherits it) accepts an optional `identity_assertion=IdentityAssertion(trusted_issuers=[...])`. When configured, the token endpoint accepts the RFC 7523 `urn:ietf:params:oauth:grant-type:jwt-bearer` grant carrying an enterprise IdP-issued ID-JAG, validates it (signature against the trusted issuer's JWKS, `iss`/`aud`/`exp`, `typ` of `oauth-id-jag+jwt`, mandatory `sub`, signed `client_id`/`resource` binding, and `jti` replay rejection), and mints a short-lived FastMCP access token carrying the asserted subject with no refresh token. Authorization server metadata advertises the `jwt-bearer` grant type and the `urn:ietf:params:oauth:grant-profile:id-jag` profile when enabled. This is server-side only; the client-side wrapper ships separately. See [Identity Assertion](https://gofastmcp.com/servers/auth/oauth-proxy#identity-assertion-sep-990). *Verify:* `fastmcp_slim/fastmcp/server/auth/identity_assertion.py`, the `exchange_identity_assertion` and `get_routes` changes in `fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py`, and the jwt-bearer dispatch in `fastmcp_slim/fastmcp/server/auth/auth.py` (`TokenHandler._maybe_handle_id_jag`). @@ -518,7 +550,7 @@ FastMCP keeps its own DCR redirect-URI hardening (PRs #4419, #4408) regardless o Every templated resource now has its extracted parameter values screened for path-traversal (`..` segments), absolute paths, and null bytes **before the handler runs** — on by default, at the server's read chokepoint, covering local and provider-sourced (mounted/proxied) templates alike. Previously these payloads reached handlers raw; a template whose parameter flowed into a filesystem path or upstream URL was exposed unless the author added their own check. A rejected read now surfaces a non-leaky "resource not found" error (`-32602`) and a debug log. -The check is component-based, matching the SDK's `contains_path_traversal`: only a standalone `..` segment is traversal, so values that merely contain dots (`HEAD~3..HEAD`, `file.tar.gz`) and dotfiles (`.env`) still pass. This can break a template that legitimately accepts `..`-bearing or absolute values — exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](/servers/resources#path-security). +The check is component-based, matching the SDK's `contains_path_traversal`: only a standalone `..` segment is traversal, so values that merely contain dots (`HEAD~3..HEAD`, `file.tar.gz`) and dotfiles (`.env`) still pass. This can break a template that legitimately accepts `..`-bearing or absolute values — exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](https://gofastmcp.com/servers/resources#path-security). *Verify:* `fastmcp_slim/fastmcp/resources/security.py` (`ResourceSecurity`), the screening block in `FastMCP.read_resource` (`fastmcp_slim/fastmcp/server/server.py`), and `tests/resources/test_resource_security.py`. @@ -551,9 +583,13 @@ The `_REMOVED_KWARGS` constructor shim (which raises helpful `TypeError`s for kw ### Tool and component parameters -- **Tool-level `serializer` parameter** — removed from `@tool` / `mcp.tool()`, `Tool.from_function`, `Tool.from_tool`, `TransformedTool.from_tool`, the OpenAPI `OpenAPITool`, and the `mcp_mixin` tool decorator. Return a `ToolResult` from your tool for full control over serialization instead (see [Custom Serialization](/servers/tools#custom-serialization)). The server-level `tool_serializer` constructor kwarg was already removed in 3.0. +- **Tool-level `serializer` parameter** — removed from `@tool` / `mcp.tool()`, `Tool.from_function`, `Tool.from_tool`, `TransformedTool.from_tool`, the OpenAPI `OpenAPITool`, and the `mcp_mixin` tool decorator. Return a `ToolResult` from your tool for full control over serialization instead (see [Custom Serialization](https://gofastmcp.com/servers/tools#custom-serialization)). The server-level `tool_serializer` constructor kwarg was already removed in 3.0. - **Tool `exclude_args` parameter** — removed from the tool decorator and its plumbing (`ParsedFunction.from_function`, `Tool.from_function`, `mcp.tool()`). Use dependency injection with `Depends()` to hide parameters from the tool schema instead. - **`decorator_mode` setting** (`FASTMCP_DECORATOR_MODE`) and its `"object"` mode — removed. Decorators always return the original function with metadata attached; the object-returning machinery is gone. Access component objects through the server (e.g. `await mcp.get_tool("name")`) rather than the decorated function. -- **Component-import compatibility shims** — the `__getattr__` shims that re-exported `FunctionTool` / `ParsedFunction` / `tool` from `fastmcp.tools.tool`, `FunctionResource` / `resource` from `fastmcp.resources.resource`, and `FunctionPrompt` / `prompt` from `fastmcp.prompts.prompt` are removed. Import these from their canonical modules (`fastmcp.tools.function_tool`, `fastmcp.resources.function_resource`, `fastmcp.prompts.function_prompt`) instead. +- **Component-import compatibility shims** — Breaking. `fastmcp.tools.tool`, `fastmcp.resources.resource`, and `fastmcp.prompts.prompt` no longer exist as modules. Two separate mechanisms kept them alive and both are now gone: the `__getattr__` shims that re-exported `FunctionTool` / `ParsedFunction` / `tool`, `FunctionResource` / `resource`, and `FunctionPrompt` / `prompt`; and the `sys.modules` aliases that pointed each old module name at its renamed `base.py`. Import the component types from the package itself — `from fastmcp.tools import Tool, ToolResult` — and the function-backed classes from their canonical modules (`fastmcp.tools.function_tool`, `fastmcp.resources.function_resource`, `fastmcp.prompts.function_prompt`). +- **`fastmcp.experimental.sampling`** and **`fastmcp.experimental.sampling.handlers`** (2.x-era re-export shims) — Breaking. These aliased the client-side sampling handlers without warning. Import from `fastmcp.client.sampling.handlers.openai` instead. Note this is unrelated to the SEP-2577 removal of *server-initiated* sampling: a FastMCP client still answers a legacy-era server's sampling requests, so `Client(sampling_handler=...)` and the Anthropic / OpenAI / Google GenAI handlers under `fastmcp.client.sampling.handlers` remain fully supported. +- **`fastmcp.server.auth.authorization`** (3.0-era re-export shim) — Breaking. The module was a pass-through sitting between the `fastmcp.server.auth` package and the real implementation in `fastmcp.utilities.authorization`, and FastMCP's own middleware and local-provider decorators imported through it. Everything internal now imports from `fastmcp.utilities.authorization` directly. The documented public path is unchanged: `from fastmcp.server.auth import require_scopes, require_roles, restrict_tag, run_auth_checks, AuthCheck, AuthContext`. Two names the old module also exported — `run_auth_checks_with_shortfall` and `scope_requirements` — are *not* re-exported from `fastmcp.server.auth` and must be imported from `fastmcp.utilities.authorization`. They are middleware plumbing with no documented user-facing use, so they were deliberately not widened onto the auth package's surface; the upgrade guide names the utilities path for them explicitly. +- **`SkillsProvider`** (3.0-era rename alias) — Breaking. Use `SkillsDirectoryProvider` from `fastmcp.server.providers.skills`. The alias was also re-exported from `fastmcp.server.providers`; both are gone. +- **`ctx.elicit()` without `response_type`** (deprecated 3.2, warned through 3.4.4) — Breaking. The parameter is now required, and passing `None` explicitly raises `TypeError`. The empty-object schema it produced was ambiguous under the MCP spec and left some clients (e.g. VS Code) rendering an empty, non-functional form. Pass a type describing the data you expect back; `bool` covers confirmations. This is the server-authoring API only — the *client* elicitation handler still receives `response_type=None` for URL requests and for empty schemas sent by other servers, which is unchanged. *Verify:* deletions of `fastmcp_slim/fastmcp/server/proxy.py`, `fastmcp_slim/fastmcp/server/openapi/`, `fastmcp_slim/fastmcp/experimental/server/openapi/`, `fastmcp_slim/fastmcp/experimental/utilities/openapi/`, `fastmcp_slim/fastmcp/server/apps.py`, `fastmcp_slim/fastmcp/server/app.py`; the removed classes in `fastmcp_slim/fastmcp/server/middleware/tool_injection.py`; the removed parameter in `fastmcp_slim/fastmcp/client/transports/http.py`; `fastmcp_slim/fastmcp/server/server.py`; `fastmcp_slim/fastmcp/tools/base.py`, `tools/function_tool.py`, `tools/tool_transform.py`, `tools/function_parsing.py`; `fastmcp_slim/fastmcp/settings.py`, `resources/function_resource.py`, `prompts/function_prompt.py`, and the local-provider decorators; `resources/base.py`, `prompts/base.py`. diff --git a/docs/development/v4-notes/feature-program.mdx b/dev-docs/v4-notes/feature-program.md similarity index 78% rename from docs/development/v4-notes/feature-program.mdx rename to dev-docs/v4-notes/feature-program.md index 289a26778..8c7b9f022 100644 --- a/docs/development/v4-notes/feature-program.mdx +++ b/dev-docs/v4-notes/feature-program.md @@ -13,21 +13,15 @@ Code blocks marked as sketches show the *intended* API and do not resolve agains ## Sampling removal -**Status: Deprecation and era-gating shipped (#4448); removal slated for 4.0.** +**Status: Shipped in 4.0.** -Sampling is the push-shaped API where a server borrows the client's model mid-call (`ctx.sample`, `ctx.sample_step`). The `2026-07-28` era removes server-initiated requests, so this API cannot work on modern connections. Background-task sampling is dead under v2 — a worker's back-channel is gone once the submitting request returns, and no sampling relay was ever built (sdk-feedback #9). +Sampling was the push-shaped API where a server borrows the client's model mid-call (`ctx.sample`, `ctx.sample_step`). The `2026-07-28` era removes server-initiated requests, so it cannot work on modern connections, and `Client`'s flip to `mode="auto"` made a modern connection the default — the era gate had become the default experience rather than an edge case. Background-task sampling was dead under v2 in any event: a worker's back-channel is gone once the submitting request returns, and no relay was ever built (sdk-feedback #9). -The plan is Option A: **deprecate the push-sampling API now and remove it in the 4.0 release.** The first two steps shipped in #4448: +Deprecation and era-gating shipped in #4448. The removal completes the plan: `ctx.sample`, `ctx.sample_step`, `ctx.list_roots`, `server/sampling/` (including `SamplingTool` and structured-result sampling), `FastMCP(sampling_handler=..., sampling_handler_behavior=...)`, and `examples/sampling/` are all gone. The server-authoring API is now the modern protocol's API, with nothing in it that only works against old clients. -- **Done:** `ctx.sample` / `ctx.sample_step` emit a `FastMCPDeprecationWarning` (once per process, gated on `settings.deprecation_warnings`). -- **Done:** both are era-gated to raise a clear, era-aware `ToolError` on `2026-07-28` before the wire, which also fixed the opaque "Method not found" of sdk-feedback #10. -- **Pending 4.0:** remove `ctx.sample`, `ctx.sample_step`, `server/sampling/`, `SamplingTool`, and structured-result sampling. +The migration story is honest: there is **no drop-in**. The guidance is architectural — call an LLM from your server directly, with your own API key, rather than borrowing the client's model. For roots, take paths as tool arguments or ask through the guard pattern, whose `input_requests` map still carries a `ListRootsRequest`. -The migration story is honest: there is **no drop-in** on modern connections. The guidance is architectural — call an LLM from your server directly, with your own API key, rather than borrowing the client's model. That shift is the real answer, and it is why the removal justifies a major version. - -The client-side provider handlers (Anthropic, OpenAI, Google GenAI) are **retained** regardless: MRTR needs them to answer sampling input-requests from the client side. What is removed is the server-side push emitter, which the SDK never built for the modern era. - -Sampling still functions on the legacy eras. Users also see an SDK-level `MCPDeprecationWarning` on ordinary `ctx.sample` usage (the SDK deprecated the capability wire-side per SEP-2577). FastMCP's own deprecation — the warning with migration guidance, plus the era-gating — shipped in #4448; only the final removal remains for 4.0. +The client-side provider handlers (Anthropic, OpenAI, Google GenAI) and `Client(sampling_handler=..., roots=...)` are **retained**: a FastMCP client still has to answer a legacy server's requests, and MRTR needs them from the client side. What is removed is the server-side push emitter. `ProxyClient`'s default relay handlers are retained for the same interop reason and now call the SDK session directly. ## MRTR elicitation @@ -35,11 +29,11 @@ Sampling still functions on the legacy eras. Users also see an SDK-level `MCPDep Elicitation survives the modern era through multi-round-trip (MRTR). The 2026 wire envelope carries elicitation as a multi-round input-request: a tool returns an `InputRequiredResult` and re-runs per round, each round a complete request→response cycle. Imperative `ctx.elicit` relies on the session back-channel, which is gone on `2026-07-28` foreground calls; on the modern era, elicitation is reachable through MRTR instead. -The **guard form** of this is shipped in 4.0 (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)): a tool returns an `InputRequiredResult` and reads the client's answers off `ctx.input_responses` / `ctx.request_state`, re-running each round. It mirrors the SDK's base guard model exactly — no FastMCP-invented DX, the framework owns `request_state` sealing, and returning this result on a handshake-era connection produces a clear era error. +The **guard form** of this is shipped in 4.0 (see [Elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)): a tool returns an `InputRequiredResult` and reads the client's answers off `ctx.input_responses` / `ctx.request_state`, re-running each round. It mirrors the SDK's base guard model exactly — no FastMCP-invented DX, the framework owns `request_state` sealing, and returning this result on a handshake-era connection produces a clear era error. What remains is the declarative `Resolve(...)` layer that sits *on top of* that shipped primitive. It is designed, not built: a new `fastmcp.elicitation` module — `Resolve`, `Elicit`, and `ElicitationResult` — thin wrappers over the SDK's resolver, wired into FastMCP's own tool layer (FastMCP tools do not inherit the SDK's auto-resolver wiring). It would detect `Annotated[_, Resolve(...)]` parameters, build resolver plans, and return the SDK's `InputRequiredResult` instead of the tool body on the first round. -Imperative `ctx.elicit` is **not** re-plumbed to survive the modern era. It works on the legacy eras through the session back-channel, and on `2026-07-28` foreground calls it is era-gated to raise a clear error (shipped in #4448) pointing at the guard form. The earlier plan to keep imperative `ctx.elicit` alive on modern connections through a background-task relay is dead twice over: the guard model shipped in its place, and the 2025 task machinery the relay depended on is slated for removal (see [Known Gaps](/development/v4-notes/known-gaps#the-xfail-register)). +Imperative `ctx.elicit` is **not** re-plumbed to survive the modern era. It works on the legacy eras through the session back-channel, and on `2026-07-28` foreground calls it is era-gated to raise a clear error (shipped in #4448) pointing at the guard form. The earlier plan to keep imperative `ctx.elicit` alive on modern connections through a background-task relay is dead twice over: the guard model shipped in its place, and the 2025 task machinery the relay depended on is slated for removal (see [Known Gaps](known-gaps.md#the-xfail-register)). The intended declarative DX (sketch — the module does not exist yet): @@ -100,7 +94,7 @@ The migration already routed `initialize` interception through the SDK's `Server The decision here was **compose, not wrap** (D16): rebuild `fastmcp.Client` on the SDK's high-level `mcp.Client` rather than wrapping `mcp.ClientSession`. The parts that compose cleanly have shipped. The rest is **blocked upstream on two counts**. First, `mcp.Client` constructs its `ClientSession` at a single hardcoded site with no injection hook, while FastMCP's `session_class` is load-bearing (`ProxyClient` substitutes a session that skips result validation so a backend's schema violation surfaces at the end client rather than becoming a proxy error) — a `session_factory=` hook on `mcp.Client`, the same shape as the `notification_bindings=` parameter added earlier, would solve this. Second, `mcp.Client.__aenter__` refuses reentry, but FastMCP's client is deliberately reentrant (its refcounted context manager exists to fix a proxy session-reuse deadlock), so the rebuild also needs the SDK client to tolerate reentrant entry. Both must land upstream before the full rebuild is possible; `session_factory=` alone is necessary but not sufficient. -This workstream also owns the server-side statelessness design holes — `ctx.session_id` / `set_state` round-tripping and stateful-proxy affinity — since they turn on the same "what is a session without a session?" question. See [Statelessness on 2026-07-28](/development/v4-notes/known-gaps#statelessness-on-2026-07-28) for the full accounting. +This workstream also owns the server-side statelessness design holes — `ctx.session_id` / `set_state` round-tripping and stateful-proxy affinity — since they turn on the same "what is a session without a session?" question. See [Statelessness on 2026-07-28](known-gaps.md#statelessness-on-2026-07-28) for the full accounting. ## Subscriptions, cache hints, extensions, OTel @@ -109,7 +103,7 @@ This workstream also owns the server-side statelessness design holes — `ctx.se A cluster of protocol features tracked for v4. Their statuses have diverged: - **Cache hints — shipped (#4464).** Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`, SEP-2549) stamps every cacheable result, and the FastMCP client honors hints with an opt-in response cache. -- **OpenTelemetry — shipped (#4481).** Spans are on by default (a no-op without an exporter), with SDK-aligned attributes and a `FASTMCP_ENABLE_TELEMETRY=false` off-switch. +- **OpenTelemetry — shipped (#4481).** Spans are on by default (a no-op without an exporter), with SDK-aligned attributes and a `FASTMCP_TELEMETRY_MODE` setting (`native` / `propagation_only` / `off`). - **Extensions — client side shipped (#4572).** `Client(extensions=..., result_claims=...)` advertises opt-in client extensions (SEP-2133). The server side is a Designed workstream in its own right (see [FastMCP-native extension API](#fastmcp-native-extension-api)). The cross-era reconciliation of the `extensions` / MCP Apps capability advertisement is still open (the capability is stripped at pre-2026 negotiated versions — sdk-feedback #2). - **Subscriptions — not started.** A `subscriptions/listen` surface backed by a subscription bus. @@ -121,7 +115,7 @@ MCP extensions (SEP-2133) are optional, capability-negotiated protocol features FastMCP already forwards `ClientExtension` natively (`Client(extensions=...)`, #4572). The **server** side does not use the SDK's `Extension` class at all: MCP Apps predates the abstraction, so FastMCP hand-splices the `ui` capability into `get_capabilities()` on the low-level server and walks tool metadata directly. That worked for one extension, but every new protocol extension currently means bespoke surgery on core. -The Designed work is a FastMCP-native server extension API — a single registration point (`mcp.add_extension(...)`) that contributes a negotiated capability, request methods, and a `tools/call` interceptor, with access to FastMCP-level constructs the SDK's `Extension` withholds (the component registry, `Context`, auth scope). It is designed against the SEP-2663 tasks extension because tasks exercises the full surface — capability *and* methods *and* interception *and* client claims/notifications — where MCP Apps exercises only a subset. Tasks is the pathfinder; MCP Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices, and confirms the design generalizes. The discriminator that keeps the extension API distinct from [middleware](/servers/middleware): an extension is a *negotiated contract change* the client must understand, where middleware is unilateral server behavior the client never sees. Delete a capability advertisement and nothing about the client changes — that is middleware, not an extension. +The Designed work is a FastMCP-native server extension API — a single registration point (`mcp.add_extension(...)`) that contributes a negotiated capability, request methods, and a `tools/call` interceptor, with access to FastMCP-level constructs the SDK's `Extension` withholds (the component registry, `Context`, auth scope). It is designed against the SEP-2663 tasks extension because tasks exercises the full surface — capability *and* methods *and* interception *and* client claims/notifications — where MCP Apps exercises only a subset. Tasks is the pathfinder; MCP Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices, and confirms the design generalizes. The discriminator that keeps the extension API distinct from [middleware](https://gofastmcp.com/servers/middleware): an extension is a *negotiated contract change* the client must understand, where middleware is unilateral server behavior the client never sees. Delete a capability advertisement and nothing about the client changes — that is middleware, not an extension. ## Background tasks (SEP-2663) @@ -129,7 +123,7 @@ The Designed work is a FastMCP-native server extension API — a single registra Background tasks return to the modern era as `fastmcp-tasks`, an in-repo optional package rebuilt on the `io.modelcontextprotocol/tasks` extension (SEP-2663, Final, merged upstream 2026-05-15). SEP-2663 supersedes SEP-1686 but keeps its polling core: a client that advertises the tasks capability issues an augmented `tools/call`; the server decides whether to run it as a task and returns a `CreateTaskResult` carrying a server-generated task id; the client polls `tasks/get` until terminal and reads the result inlined there. FastMCP's existing SEP-1686 wire layer is removed while the Docket/Redis execution engine underneath moves into `fastmcp-tasks` intact — the spec moved toward what FastMCP already built, so the rebuild is mostly deletion plus a thin wire adapter. `task=True` stays the authoring surface (gated by the `fastmcp[tasks]` extra and an explicit `mcp.add_extension(TasksExtension(...))`, the first consumer of the [extension API](#fastmcp-native-extension-api) above), so a server that already uses tasks needs no code change. Scope for v1 is polling-only and `tools/call`-only. -The full design — wire delta, the engine/wire split, packaging, client experience, sequencing, risks, and the five resolved decisions — is on the dedicated [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) page. +The full design — wire delta, the engine/wire split, packaging, client experience, sequencing, risks, and the five resolved decisions — is on the dedicated [Background Tasks (SEP-2663)](background-tasks.md) page. ## SDK delegation, round two @@ -141,6 +135,6 @@ The real HTTP simplification is a v4 project, not this PR. FastMCP can collapse 2. a user-middleware injection hook, 3. a lifespan hook. -The payoff is not only less code — FastMCP would also inherit the SDK's session-owner credential enforcement, a security gain it lacks today. These are the three upstream feature requests to file (alongside the advisory dossier described in [Known Gaps](/development/v4-notes/known-gaps)). Until they land, the four HTTP overrides in the [Change Register](/development/v4-notes/change-register#http) stay. +The payoff is not only less code — FastMCP would also inherit the SDK's session-owner credential enforcement, a security gain it lacks today. These are the three upstream feature requests to file (alongside the advisory dossier described in [Known Gaps](known-gaps.md)). Until they land, the four HTTP overrides in the [Change Register](change-register.md#http) stay. One latent capability worth surfacing on FastMCP's side: `session_idle_timeout` is accepted by the manager but never set by `create_streamable_http_app` — a one-line plumb if FastMCP wants to expose it. diff --git a/docs/development/v4-notes/index.mdx b/dev-docs/v4-notes/index.md similarity index 76% rename from docs/development/v4-notes/index.mdx rename to dev-docs/v4-notes/index.md index a320bb6e7..282c37af2 100644 --- a/docs/development/v4-notes/index.mdx +++ b/dev-docs/v4-notes/index.md @@ -4,9 +4,9 @@ title: v4.0 Development Notes This directory is the working map of FastMCP v4.0: the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), plus the forward v4 feature program. It plays three roles at once. -1. **A change register.** Every user-visible change from the migration, organized by subsystem, with a note on how FastMCP handles it (absorbed, bridged, breaking, or deprecated) and where to find it in the diff. This is the [Change Register](/development/v4-notes/change-register). -2. **A feature program.** The forward v4 work — sampling removal, multi-round-trip elicitation, the first-class 2026 client, a FastMCP-native extension API, the SEP-2663 background-tasks rebuild, and the SDK-delegation round-two convergence — now a mix of shipped, designed, and pending. Multi-round-trip guard tools (#4544), the client's `mode="auto"` default with a partial SDK-composition (#4572/#4574, full composition blocked upstream), the extension API (#4602), and background tasks on SEP-2663 (#4603) have shipped; sampling removal and SDK delegation remain ahead. Each carries an explicit status in the [Feature Program](/development/v4-notes/feature-program). The shipped side — what a v4 deployment provides on the modern protocol today, including the complete server-side SEP-990 identity assertion implementation — is cataloged in [2026-07-28 Protocol Support](/development/v4-notes/protocol-2026). -3. **A review lens.** Because the migration PR is too large to review line by line, the change register is organized so a reviewer can take one subsystem, read its claimed changes, and verify each against the diff. The [Known Gaps](/development/v4-notes/known-gaps) page collects the deliberate xfails and the upstream dependencies that gate the follow-up work. +1. **A change register.** Every user-visible change from the migration, organized by subsystem, with a note on how FastMCP handles it (absorbed, bridged, breaking, or deprecated) and where to find it in the diff. This is the [Change Register](change-register.md). +2. **A feature program.** The forward v4 work — sampling removal, multi-round-trip elicitation, the first-class 2026 client, a FastMCP-native extension API, the SEP-2663 background-tasks rebuild, and the SDK-delegation round-two convergence — now a mix of shipped, designed, and pending. Multi-round-trip guard tools (#4544), the client's `mode="auto"` default with a partial SDK-composition (#4572/#4574, full composition blocked upstream), the extension API (#4602), and background tasks on SEP-2663 (#4603) have shipped; sampling removal and SDK delegation remain ahead. Each carries an explicit status in the [Feature Program](feature-program.md). The shipped side — what a v4 deployment provides on the modern protocol today, including the complete server-side SEP-990 identity assertion implementation — is cataloged in [2026-07-28 Protocol Support](protocol-2026.md). +3. **A review lens.** Because the migration PR is too large to review line by line, the change register is organized so a reviewer can take one subsystem, read its claimed changes, and verify each against the diff. The [Known Gaps](known-gaps.md) page collects the deliberate xfails and the upstream dependencies that gate the follow-up work. ## Why v4 exists @@ -16,13 +16,13 @@ FastMCP v4.0 is an engine swap. Three forces drive the major version: **Protocol version 2026-07-28.** The SDK v2 serves multiple protocol eras from one server. Alongside the session-based handshake eras, it introduces the sessionless `2026-07-28` era, which discovers capabilities through `server/discover` and removes server-initiated requests (SEP-2577). This formally supersedes FastMCP's earlier "latest protocol only" stance: a single server now works with clients across the protocol transition. -**Sampling removal.** The `2026-07-28` era removes the server's ability to push a request back to the client mid-call. That takes the push-shaped sampling API (`ctx.sample`, `ctx.sample_step`) off the table on modern connections. Rather than leave it half-working, v4 deprecates it now and removes it in the 4.0 release — a real architectural shift for servers that borrowed the client's model, and one that justifies the major bump. +**Sampling and roots removed from the server API.** The `2026-07-28` era removes the server's ability to push a request back to the client mid-call, which takes `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` off the table. Rather than leave them half-working against old clients only, 4.0 removes them from the server API entirely — a real architectural shift for servers that borrowed the client's model, and one that justifies the major bump. Client-side handlers stay, because a modern client still has to answer a legacy server. ## Release strategy The migration merges to `main` and development continues there with subsequent PRs. Releases follow the SDK's own beta timeline: -- **`main` carries the beta pins.** While the SDK is on `mcp==2.0.0b1` / `mcp-types==2.0.0b1`, `main` cuts **pre-releases** (`4.0.0b1`, `4.0.0b2`, …). No stable PyPI release goes out until `mcp 2.0.0` reaches GA — at which point the pins swap to the stable SDK and `4.0.0` ships. The pin-swap is a tracked checklist item on the [Known Gaps](/development/v4-notes/known-gaps) page. +- **`main` carries the beta pins.** While the SDK is on `mcp==2.0.0b1` / `mcp-types==2.0.0b1`, `main` cuts **pre-releases** (`4.0.0b1`, `4.0.0b2`, …). No stable PyPI release goes out until `mcp 2.0.0` reaches GA — at which point the pins swap to the stable SDK and `4.0.0` ships. The pin-swap is a tracked checklist item on the [Known Gaps](known-gaps.md) page. - **`release/3.x` is the maintenance line.** A `release/3.x` branch is cut from pre-merge `main`. It stays on the SDK v1 line, receives upstream security patches, and serves users who cannot move to the SDK v2 beta yet. ### Release codenames @@ -39,11 +39,11 @@ Following the pun-title convention (`v: `), the v4 line runs a sin ## How to read the register -Each subsystem section in the [Change Register](/development/v4-notes/change-register) tags its changes with one of four dispositions: +Each subsystem section in the [Change Register](change-register.md) tags its changes with one of four dispositions: - **Absorbed** — the SDK changed underneath, but FastMCP's public surface is identical. Nothing for users to do. - **Bridged** — a compatibility shim keeps old code working, usually with a `FastMCPDeprecationWarning`. Users should migrate but are not forced to. - **Breaking** — user code must change. These are the headline migration items. - **Deprecated** — still works, warns now, slated for removal in a later release. -The user-facing summary of the migration lives in the published [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) guide. These development notes are the exhaustive version behind it. +The user-facing summary of the migration lives in the published [Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3) guide. These development notes are the exhaustive version behind it. diff --git a/docs/development/v4-notes/known-gaps.mdx b/dev-docs/v4-notes/known-gaps.md similarity index 89% rename from docs/development/v4-notes/known-gaps.mdx rename to dev-docs/v4-notes/known-gaps.md index d6ad9b12a..0fb699029 100644 --- a/docs/development/v4-notes/known-gaps.mdx +++ b/dev-docs/v4-notes/known-gaps.md @@ -8,7 +8,7 @@ The migration ships with a set of deliberate gaps: temporary shims, xfailed test Roughly forty `xfail` markers across the test tree name the SDK gaps and removed protocol surfaces they wait on. Re-running the suite against a new SDK beta surfaces which have closed (a strict xfail that starts passing fails the suite, prompting removal of the marker). They cluster in three areas — but the largest cluster is no longer a set of gaps to close. -**Task suite (`tests/server/tasks/`, `tests/client/tasks/`) — SEP-1686 wire layer being removed; engine rebuilt on SEP-2663.** The large majority. These cover the 2025 task protocol (SEP-1686), which left the core MCP spec and was reworked into the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP's SEP-1686 *wire* machinery (capability advertisement, the `tasks/get|result|list|cancel` handlers, the push notification/elicitation relay) is slated for removal, so the wire-protocol xfails disappear with the code they cover — they are not waiting on an SDK fix. The Docket/Redis *execution engine* underneath is not discarded: it is extracted into the planned `fastmcp-tasks` package and re-adapted to the SEP-2663 polling shape (see [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks)). The two SDK gaps these were originally filed against — **sdk-feedback #1** (SEP-1686 task result types omitted from the method registries) and **sdk-feedback #3** (no `task` field on `ReadResourceRequestParams` / `GetPromptRequestParams`) — are moot: they patched the SEP-1686 wire shape, which SEP-2663 replaces with a `CreateTaskResult` claimed on `tools/call`. The gap that matters for the rebuild is **sdk-feedback #2** (extensions capability stripped at pre-2026 negotiated versions) — it now gates a flagship feature and is escalated accordingly. +**Task suite (`tests/server/tasks/`, `tests/client/tasks/`) — SEP-1686 wire layer being removed; engine rebuilt on SEP-2663.** The large majority. These cover the 2025 task protocol (SEP-1686), which left the core MCP spec and was reworked into the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP's SEP-1686 *wire* machinery (capability advertisement, the `tasks/get|result|list|cancel` handlers, the push notification/elicitation relay) is slated for removal, so the wire-protocol xfails disappear with the code they cover — they are not waiting on an SDK fix. The Docket/Redis *execution engine* underneath is not discarded: it is extracted into the planned `fastmcp-tasks` package and re-adapted to the SEP-2663 polling shape (see [Background Tasks (SEP-2663)](background-tasks.md)). The two SDK gaps these were originally filed against — **sdk-feedback #1** (SEP-1686 task result types omitted from the method registries) and **sdk-feedback #3** (no `task` field on `ReadResourceRequestParams` / `GetPromptRequestParams`) — are moot: they patched the SEP-1686 wire shape, which SEP-2663 replaces with a `CreateTaskResult` claimed on `tools/call`. The gap that matters for the rebuild is **sdk-feedback #2** (extensions capability stripped at pre-2026 negotiated versions) — it now gates a flagship feature and is escalated accordingly. **Protocol eras (`tests/server/test_protocol_eras.py`).** One remaining strict xfail, and it too is task-related: the v2 SDK high-level client exposes no `task=` parameter on `call_tool`, so a SEP-1686 task-augmented `tools/call` cannot be submitted through it. It resolves with the SEP-1686 wire-layer removal above; the SEP-2663 rebuild submits tasks by advertising the extension capability and claiming a `CreateTaskResult`, not through a `task=` params field. The earlier strict xfail for the `ctx.elicit` / `ctx.sample` "Method not found" degradation (sdk-feedback #10) is **gone** — the era-gating shipped in #4448 flipped it to a passing test. @@ -53,7 +53,7 @@ These work on `2026-07-28` today because they never leaned on a protocol session ### Design holes deferred to the multi-protocol workstream -The remaining items are real holes, deferred to the [first-class 2026 client](/development/v4-notes/feature-program#first-class-2026-client) workstream because they all reduce to one unanswered question — *what is a session when the protocol has none?* The danger in each is that the code currently returns without erroring, which reads as "works" but is actually silent degradation. Again: these affect `2026-07-28` connections only; on the handshake eras every one of them behaves correctly. +The remaining items are real holes, deferred to the [first-class 2026 client](feature-program.md#first-class-2026-client) workstream because they all reduce to one unanswered question — *what is a session when the protocol has none?* The danger in each is that the code currently returns without erroring, which reads as "works" but is actually silent degradation. Again: these affect `2026-07-28` connections only; on the handshake eras every one of them behaves correctly. - **`ctx.session_id` and `ctx.set_state` / `ctx.get_state` (broken even single-replica).** On a modern request `ctx.session_id` mints a fresh `uuid4`, cached on the per-request `connection.state` that is discarded when the request returns. So `ctx.set_state` and `ctx.get_state` silently never round-trip across requests — no error, just lost data. The open design decision is whether `session_id` should become `None` with `set_state` documented as session-era-only, or be re-based on an app-level key (the auth subject, or a client-supplied header). - **Task push and in-task input — resolved by the SEP-2663 design, not a statelessness hole.** This was previously framed as a hole because SEP-1686 leaned on a push back-channel (the notification/elicitation relay) that dies once the submitting request returns. SEP-2663 removes the dependency: in-task input is *poll-based* — the task enters `input_required`, surfaces its outstanding elicit/sample/roots requests in an `inputRequests` map on `tasks/get`, and the client answers via `tasks/update`. That round-trips through the durable store with no session affinity, so it is stateless-safe by construction. The SEP-1686 push relay (`server/tasks/elicitation.py`, `notifications.py`) is removed; the `fastmcp-tasks` rebuild implements the poll-based channel instead. Foreground (non-task) elicitation on 2026 remains the guard-mode `InputRequiredResult`. @@ -74,7 +74,7 @@ FastMCP acts as an advisor to the SDK team. The migration produced a dossier of Filing is gated on maintainer approval of each issue text. -Separately, the [SDK delegation round two](/development/v4-notes/feature-program#sdk-delegation-round-two) work depends on **three upstream feature requests** — per-session event-store scoping, a user-middleware injection hook, and a lifespan hook — that would let FastMCP collapse its HTTP builders onto the SDK's and inherit the SDK's session-owner credential enforcement. +Separately, the [SDK delegation round two](feature-program.md#sdk-delegation-round-two) work depends on **three upstream feature requests** — per-session event-store scoping, a user-middleware injection hook, and a lifespan hook — that would let FastMCP collapse its HTTP builders onto the SDK's and inherit the SDK's session-owner credential enforcement. ## GA transition checklist diff --git a/docs/development/v4-notes/protocol-2026.mdx b/dev-docs/v4-notes/protocol-2026.md similarity index 84% rename from docs/development/v4-notes/protocol-2026.mdx rename to dev-docs/v4-notes/protocol-2026.md index 73b73ac38..fb3ef5428 100644 --- a/docs/development/v4-notes/protocol-2026.mdx +++ b/dev-docs/v4-notes/protocol-2026.md @@ -23,7 +23,7 @@ auth = OAuthProxy( mcp = FastMCP("Internal API", auth=auth) ``` -Behind that one parameter, FastMCP performs the full SEP-990 §5.1 / RFC 7523 §3 processing: JWKS-based signature verification with automatic OIDC discovery of issuer keys, `typ`/`iss`/`aud`/`sub` validation, temporal checks (`exp`, `iat`, `nbf`, maximum assertion lifetime), enforcement of the assertion's signed `client_id` and `resource` bindings, `jti` replay rejection, scope derivation from the signed assertion (client requests can narrow but never widen), short-lived token issuance with no refresh token, and revocation tracking for the issued tokens. The asserted subject flows into the normal FastMCP auth context, so tools read it through `get_access_token()` like any other identity. See [Identity Assertion](/servers/auth/oauth-proxy#identity-assertion-sep-990) for the full documentation. +Behind that one parameter, FastMCP performs the full SEP-990 §5.1 / RFC 7523 §3 processing: JWKS-based signature verification with automatic OIDC discovery of issuer keys, `typ`/`iss`/`aud`/`sub` validation, temporal checks (`exp`, `iat`, `nbf`, maximum assertion lifetime), enforcement of the assertion's signed `client_id` and `resource` bindings, `jti` replay rejection, scope derivation from the signed assertion (client requests can narrow but never widen), short-lived token issuance with no refresh token, and revocation tracking for the issued tokens. The asserted subject flows into the normal FastMCP auth context, so tools read it through `get_access_token()` like any other identity. See [Identity Assertion](https://gofastmcp.com/servers/auth/oauth-proxy#identity-assertion-sep-990) for the full documentation. This slots into FastMCP's existing authorization-server stack — the OAuth proxy's dynamic client registration, the consent flow, and self-issued JWTs — which is what makes a one-parameter enterprise deployment possible. @@ -40,14 +40,14 @@ The complete picture of what a FastMCP v4 server and client provide on the `2026 | **Distributed response caching** | `KeyValueResponseCacheStore` backs the client cache with any key-value store (Redis, memory, filetree), so a fleet of clients or proxy replicas shares cache fills across processes. | | **Resource path security** | Templated resource parameters are screened for traversal, absolute paths, and null bytes before handlers run — on by default, including provider-sourced and mounted templates. | | **Client protocol negotiation** | `Client(mode="auto")` — the default as of v4 — probes `server/discover` and falls back to the classic handshake; the client answers multi-round-trip `input_required` requests through its existing handlers. Pin `mode="legacy"` to force the handshake. | -| **Elicitation on the modern protocol (SEP-2322)** | Tools request user input via multi-round trips: a tool returns an `InputRequiredResult` and re-runs per round, reading the client's answers off `ctx.input_responses` / `ctx.request_state` (the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle; the framework seals `request_state` on the wire and unseals it before the tool runs, and a shared-key `request_state_security` policy carries state across replicas. On handshake-era connections returning this result produces a clear era error. | +| **Elicitation on the modern protocol (SEP-2322)** | Tools request user input via multi-round trips: a tool returns an `InputRequiredResult` and re-runs per round, reading the client's answers off `ctx.input_responses` / `ctx.request_state` (the [guard pattern](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle; the framework seals `request_state` on the wire and unseals it before the tool runs, and a shared-key `request_state_security` policy carries state across replicas. On handshake-era connections returning this result produces a clear era error. | | **Spec-standard errors (SEP-2164)** | Missing-resource reads return `-32602`; push-feature calls on modern connections fail with clear era-specific errors rather than generic method-not-found. | | **Middleware** | Typed per-method hooks (`on_call_tool`, `on_list_tools`, …) and a suite of built-ins (auth, rate limiting, caching, error handling, logging, timing, and more). | | **Composition** | `mount()`, providers, proxying, and tool transforms compose servers dynamically at runtime, with lifespans and middleware driven through the SDK session manager. | | **Pagination** | Declarative `FastMCP(list_page_size=...)` paginates all list operations in the high-level server; the client auto-paginates with cycle detection. | -| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_ENABLE_TELEMETRY=false` disables cleanly. | -| **Background tasks (SEP-2663)** | `fastmcp-tasks` implements the `io.modelcontextprotocol/tasks` extension end to end: `mcp.add_extension(TasksExtension())` plus `task=True` runs a tool as a background task, driven by the same Docket engine FastMCP 3 used. A client transparently completes a tasked call; gathering input mid-task uses the same guard pattern as foreground multi-round-trip tools, so a tool is written once and works either way. Modern-protocol only — the `task=True` runtime this replaced (SEP-1686) is gone entirely, not bridged. See [Background Tasks (SEP-2663)](/development/v4-notes/background-tasks) for the design and [servers/tasks](/servers/tasks) for usage. | +| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_TELEMETRY_MODE` selects `native`, `propagation_only` (interop with an outer MCP instrumentation layer), or `off`. | +| **Background tasks (SEP-2663)** | `fastmcp-tasks` implements the `io.modelcontextprotocol/tasks` extension end to end: `mcp.add_extension(TasksExtension())` plus `task=True` runs a tool as a background task, driven by the same Docket engine FastMCP 3 used. A client transparently completes a tasked call; gathering input mid-task uses the same guard pattern as foreground multi-round-trip tools, so a tool is written once and works either way. Modern-protocol only — the `task=True` runtime this replaced (SEP-1686) is gone entirely, not bridged. See [Background Tasks (SEP-2663)](background-tasks.md) for the design and [servers/tasks](https://gofastmcp.com/servers/tasks) for usage. | ## Still in the program -Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](/development/v4-notes/feature-program), along with the unified `subscriptions/listen` stream. The [Known Gaps](/development/v4-notes/known-gaps) page tracks the upstream dependencies that gate them. +Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](feature-program.md), along with the unified `subscriptions/listen` stream. The [Known Gaps](known-gaps.md) page tracks the upstream dependencies that gate them. diff --git a/docs/development/v4-notes/stateless-session-state.md b/dev-docs/v4-notes/stateless-session-state.md similarity index 100% rename from docs/development/v4-notes/stateless-session-state.md rename to dev-docs/v4-notes/stateless-session-state.md diff --git a/docs/apps/architecture.mdx b/docs/apps/architecture.mdx index 4eaad9023..727697094 100644 --- a/docs/apps/architecture.mdx +++ b/docs/apps/architecture.mdx @@ -61,17 +61,43 @@ The final tool result has two parts: `content` (a list of `TextContent` blocks f ## Tool call routing -Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters) before resolving by name. App UI calls need a different path. +A tool has two things that behave very differently. Its **name** is unstable by design — namespace transforms rename it, so `save_contact` becomes `contacts_save_contact` in one composition and something else in another. Its **identity** is a hash of the app name and the registered tool name, written once at registration and never changed. -### The hashed lookup bypass +A UI is serialized during the entry tool's call, deep inside whatever composition the server happens to have, so it cannot know what its backend tools will be called by the time the payload reaches a host. -Backend tools are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — while the renderer needs a stable way to call the original backend. +### Late-bound tool names -Hashed lookup solves both problems. FastMCP first tries normal tool resolution. If no visible tool matches and the requested name looks like `_`, FastMCP calls `get_tool_by_hash(hash, local_name)`. That lookup walks the provider tree directly, skipping transforms. It finds an app-visible tool by its original registered name and verifies that its stored `meta["fastmcp"]["_tool_hash"]` matches the requested hash. +The payload leaves the app addressed by identity, and every FastMCP server rewrites those references on the way out to whatever it lists that tool as. Servers unwind innermost-first, so the outermost server rewrites last — and its names are the only ones a client can actually invoke. -That's why `CallTool(save_contact)` keeps working when the server is mounted under a namespace. The renderer sends a deterministic hashed backend name; the server uses `get_tool_by_hash` to find the original tool without transforms in the way. +Rewriting a name in place would destroy the identity for the next layer up, so the payload carries a name-to-identity map under `_meta.fastmcp.toolNames`. Each layer resolves through the map and updates it. The action objects keep the exact shape `prefab_ui` defines: only the value of `tool` changes, and only ever to another valid tool name. -Authorization still applies. The hashed bypass skips name and visibility transforms, but auth checks still run against the tool's `auth` config before execution. +The result is that a renderer receives names that exist in the listing the host is looking at. Under three layers of namespacing the button calls `c_b_a_save`; behind a gateway it calls whatever the gateway lists. No intermediary has to understand a FastMCP-specific convention. + +A reference this server cannot resolve is left alone rather than corrupted. This is what keeps apps working behind [tool search](/servers/transforms/tool-search) and code mode, which replace `tools/list` with a handful of synthetic tools: there is no better name to bind to, so the reference stays identity-addressed and the fallback below carries it. + +### One copy of an app per server + +**An app name must be unique within a server.** Composing the same app twice breaks its UI, and no namespace or mount arrangement makes it work. + +The reason is structural. Identity is derived from the app name and the tool's registered name, and deliberately nothing else — that is what makes it survive renaming. Two copies of one app therefore produce two tools claiming a single identity, and no fact anywhere in the listing says which copy a given button belongs to. The information needed to choose was never recorded. + +FastMCP declines to bind rather than picking a copy, so buttons stop working instead of quietly invoking the wrong tenant's tool. Expect a message naming the cause: + +``` +Ambiguous app tool 'save': 2 components share the identity '10c0803009ff'. +The same app is composed more than once, so this call cannot be routed to a +single tool. +``` + +Give each copy its own app name. Two tenants running the same product want `FastMCPApp("contacts-acme")` and `FastMCPApp("contacts-globex")` — not two instances of `FastMCPApp("contacts")` under different namespaces, since namespaces rename tools and identity is immune to renaming by design. + +### The hashed lookup fallback + +The identity-addressed form `_` remains callable. FastMCP first tries normal tool resolution; if no tool matches and the name has that shape, it calls `get_tool_by_hash(hash, local_name)`, which walks the provider tree directly, skipping transforms. + +When one identity is claimed by more than one tool — which happens when the same app is composed into two branches — the call is refused rather than resolved, since picking either one would silently route into the wrong branch. + +Authorization still applies. The hashed path skips name and visibility transforms, but auth checks still run against the tool's `auth` config before execution. ### Provider delegation diff --git a/docs/apps/fastmcp-app.mdx b/docs/apps/fastmcp-app.mdx index 55b3b7ed7..b3facd212 100644 --- a/docs/apps/fastmcp-app.mdx +++ b/docs/apps/fastmcp-app.mdx @@ -89,7 +89,11 @@ A fair question. Any [Interactive Tool](/apps/prefab) can call a server tool — - What happens to `CallTool("add_note")` when you mount this server under a namespace and the tool becomes `notes_add_note`? - How do you keep it all wired correctly as you compose servers? -`FastMCPApp` owns these concerns. Entry points register as model-visible. Backend tools register as UI-only by default. Backend tools get globally stable identifiers that survive namespacing, and `CallTool` accepts function references, so references stay valid when you compose servers. +`FastMCPApp` owns these concerns. Entry points register as model-visible, backend tools register as UI-only, and hosts act on those declarations to decide what the model sees. + +Composition is handled by never writing the name down. `CallTool` takes a function reference, and FastMCP resolves it when the UI is serialized — to whatever that tool is actually called by then. Mount the server under a namespace and the button calls `notes_add_note`; put a gateway in front and it calls whatever the gateway lists. Since you never wrote a name, renaming cannot break it. [The architecture page](/apps/architecture) covers how that resolution works. + +The one rule that comes with this: **an app name must be unique within a server.** Composing the same app twice breaks its UI — two copies of `FastMCPApp("notes")` are indistinguishable no matter what namespaces you mount them under, so FastMCP declines to bind rather than picking one. Name apps for what they serve: `FastMCPApp("notes-acme")` and `FastMCPApp("notes-globex")`. [The architecture page](/apps/architecture) explains why identity works this way. The rest of this page covers each piece in turn. diff --git a/docs/apps/low-level.mdx b/docs/apps/low-level.mdx index 14f5d331d..0cd1b0ea1 100644 --- a/docs/apps/low-level.mdx +++ b/docs/apps/low-level.mdx @@ -70,11 +70,15 @@ def my_tool() -> str: The `visibility` field controls where a tool appears: - `["model"]` — visible to the LLM (the default behavior) -- `["app"]` — only callable from within the app UI, hidden from the LLM +- `["app"]` — callable from within the app UI, kept out of the LLM's tool list - `["model", "app"]` — both This is useful when you have tools that only make sense as part of the app's interactive flow, not as standalone LLM actions. +Visibility is a declaration, and on `tools/list` the host does the filtering — the division the MCP Apps specification defines. Every tool is advertised carrying its `visibility` metadata, which is also what lets a proxy or gateway forward it: an intermediary can only route to a tool it can see. + +That division assumes a host stands between the server and the model. Where one doesn't, FastMCP applies the declaration itself. [Tool search](/servers/transforms/tool-search) and code mode reach the model as ordinary tool output rather than as an advertised listing, and their call-tool proxies execute a name the model supplies — nothing downstream can filter either, so app-only tools are excluded from both. The app's own UI still reaches its backends, because a UI calling by identity is not the model. + ```python @mcp.tool( app=AppConfig( @@ -216,7 +220,7 @@ import qrcode from fastmcp import FastMCP from fastmcp.apps import AppConfig, ResourceCSP from fastmcp.tools import ToolResult -from mcp_types import ImageContent +from mcp.types import ImageContent mcp = FastMCP("QR Code Server") diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 063bc4646..09a52bbd8 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -5,6 +5,202 @@ rss: true tag: NEW --- + + +**[v4.0.0b1: Fourgone Conclusion](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1)** + +FastMCP 4 rebuilds the framework on the MCP Python SDK v2, and this beta is the first release to run on the SDK's stable 2.0. The SDK v2 rewrote the protocol layer end to end — protocol types moved into a standalone `mcp_types` package, every model field renamed from camelCase to snake_case in Python, and the server's request-handling model replaced — and FastMCP absorbs nearly all of it, so most FastMCP 3 servers run untouched. On that foundation v4 serves the sessionless `2026-07-28` protocol and the older handshake from one server, adds stateless session state and background tasks, makes protocol extensions a first-class surface, and removes server-initiated sampling and roots from the server API. + +### New Features 🎉 +* Migrate to MCP Python SDK v2 by [@jlowin](https://github.com/jlowin) in [#4437](https://github.com/PrefectHQ/fastmcp/pull/4437) +* Teach fastmcp.Client the modern protocol: mode negotiation, MRTR driver, response cache by [@jlowin](https://github.com/jlowin) in [#4450](https://github.com/PrefectHQ/fastmcp/pull/4450) +* Forward-port Hugging Face auth provider by [@jlowin](https://github.com/jlowin) in [#4475](https://github.com/PrefectHQ/fastmcp/pull/4475) +* Add server-side identity assertion (SEP-990 ID-JAG) by [@jlowin](https://github.com/jlowin) in [#4483](https://github.com/PrefectHQ/fastmcp/pull/4483) +* Add guard-mode multi-round-trip tools (SEP-2322) by [@jlowin](https://github.com/jlowin) in [#4544](https://github.com/PrefectHQ/fastmcp/pull/4544) +* Add FastMCP-native server extension API (SEP-2133) by [@jlowin](https://github.com/jlowin) in [#4602](https://github.com/PrefectHQ/fastmcp/pull/4602) +* Add stateless session state (UserSession / SessionId) by [@jlowin](https://github.com/jlowin) in [#4604](https://github.com/PrefectHQ/fastmcp/pull/4604) +* Add background tasks via the io.modelcontextprotocol/tasks extension (SEP-2663) by [@jlowin](https://github.com/jlowin) in [#4603](https://github.com/PrefectHQ/fastmcp/pull/4603) +### Breaking Changes ⚠️ +* Emit one SERVER span per request and adopt spec-correct error codes by [@jlowin](https://github.com/jlowin) in [#4445](https://github.com/PrefectHQ/fastmcp/pull/4445) +* Remove 3.x deprecated module shims and dead parameters by [@jlowin](https://github.com/jlowin) in [#4447](https://github.com/PrefectHQ/fastmcp/pull/4447) +* Remove 3.0-deprecated FastMCP server methods by [@jlowin](https://github.com/jlowin) in [#4451](https://github.com/PrefectHQ/fastmcp/pull/4451) +* Remove 3.x deprecated parameters and object-mode decorators by [@jlowin](https://github.com/jlowin) in [#4453](https://github.com/PrefectHQ/fastmcp/pull/4453) +* Migrate to MCP SDK v2.0.0b2 (httpx2) by [@jlowin](https://github.com/jlowin) in [#4503](https://github.com/PrefectHQ/fastmcp/pull/4503) +* Fix typos by [@szepeviktor](https://github.com/szepeviktor) in [#4498](https://github.com/PrefectHQ/fastmcp/pull/4498) +* Stop proxies from validating backend results or mutating shared transports by [@jlowin](https://github.com/jlowin) in [#4552](https://github.com/PrefectHQ/fastmcp/pull/4552) +* Surface resource, prompt, and proxy errors on the modern protocol by [@jlowin](https://github.com/jlowin) in [#4579](https://github.com/PrefectHQ/fastmcp/pull/4579) +* Negotiate the best mutual protocol era by default by [@jlowin](https://github.com/jlowin) in [#4572](https://github.com/PrefectHQ/fastmcp/pull/4572) +* Remove server-initiated sampling and roots from the server API by [@jlowin](https://github.com/jlowin) in [#4648](https://github.com/PrefectHQ/fastmcp/pull/4648) +* Remove 3.x-era compatibility shims by [@jlowin](https://github.com/jlowin) in [#4661](https://github.com/PrefectHQ/fastmcp/pull/4661) +### Enhancements ✨ +* Deprecate ctx.sample and add clear errors for push features on 2026 connections by [@jlowin](https://github.com/jlowin) in [#4448](https://github.com/PrefectHQ/fastmcp/pull/4448) +* Add server-level cache hints (SEP-2549) by [@jlowin](https://github.com/jlowin) in [#4464](https://github.com/PrefectHQ/fastmcp/pull/4464) +* Add KeyValueResponseCacheStore for distributed client response caching by [@jlowin](https://github.com/jlowin) in [#4479](https://github.com/PrefectHQ/fastmcp/pull/4479) +* Test lifespan fires once per process over HTTP by [@jlowin](https://github.com/jlowin) in [#4480](https://github.com/PrefectHQ/fastmcp/pull/4480) +* Add telemetry off-switch and mcp.protocol.version span attribute by [@jlowin](https://github.com/jlowin) in [#4481](https://github.com/PrefectHQ/fastmcp/pull/4481) +* Trace client task management requests by [@jlowin](https://github.com/jlowin) in [#4525](https://github.com/PrefectHQ/fastmcp/pull/4525) +* Stabilize upgraded ty checks by [@jlowin](https://github.com/jlowin) in [#4526](https://github.com/PrefectHQ/fastmcp/pull/4526) +* Improve DescopeProvider scope discovery and well-known URL support by [@gaokevin1](https://github.com/gaokevin1) in [#4489](https://github.com/PrefectHQ/fastmcp/pull/4489) +* Add examples/ to the ty static-analysis gate by [@jlowin](https://github.com/jlowin) in [#4466](https://github.com/PrefectHQ/fastmcp/pull/4466) +* Expose telemetry attributes on span start by [@zzstoatzz](https://github.com/zzstoatzz) in [#4487](https://github.com/PrefectHQ/fastmcp/pull/4487) +* Fix-issue-4284 : Add Auth0MCPProvider for Auth0 Auth for MCP by [@vijaydeepsinha](https://github.com/vijaydeepsinha) in [#4411](https://github.com/PrefectHQ/fastmcp/pull/4411) +* Run FastMCP middleware for every inbound message by [@jlowin](https://github.com/jlowin) in [#4553](https://github.com/PrefectHQ/fastmcp/pull/4553) +* Add 'prs welcome' label to waive the PR assignment gate by [@jlowin](https://github.com/jlowin) in [#4557](https://github.com/PrefectHQ/fastmcp/pull/4557) +* Rename martian workflows to marvin by [@jlowin](https://github.com/jlowin) in [#4558](https://github.com/PrefectHQ/fastmcp/pull/4558) +* Bump pinned Claude models to current versions by [@jlowin](https://github.com/jlowin) in [#4561](https://github.com/PrefectHQ/fastmcp/pull/4561) +* Make the unit suite fast: in-process HTTP tests, no real sleeps, parallel Windows CI by [@jlowin](https://github.com/jlowin) in [#4554](https://github.com/PrefectHQ/fastmcp/pull/4554) +* Mirror the frontend's protocol era on a proxy's backend connection by [@jlowin](https://github.com/jlowin) in [#4573](https://github.com/PrefectHQ/fastmcp/pull/4573) +* Drop forked client protocol helpers in favor of the SDK's by [@jlowin](https://github.com/jlowin) in [#4574](https://github.com/PrefectHQ/fastmcp/pull/4574) +* Bring the v4 developer notes up to date with what shipped by [@jlowin](https://github.com/jlowin) in [#4581](https://github.com/PrefectHQ/fastmcp/pull/4581) +* Trim fastmcp.types to FastMCP-unique types by [@jlowin](https://github.com/jlowin) in [#4584](https://github.com/PrefectHQ/fastmcp/pull/4584) +* Let a server answer argument-completion requests by [@jlowin](https://github.com/jlowin) in [#4582](https://github.com/PrefectHQ/fastmcp/pull/4582) +* Add machine-to-machine client authentication by [@jlowin](https://github.com/jlowin) in [#4583](https://github.com/PrefectHQ/fastmcp/pull/4583) +* Expose era-neutral client server metadata by [@zzstoatzz](https://github.com/zzstoatzz) in [#4599](https://github.com/PrefectHQ/fastmcp/pull/4599) +* Support routable transport headers for gateways (SEP-2243) by [@jlowin](https://github.com/jlowin) in [#4622](https://github.com/PrefectHQ/fastmcp/pull/4622) +* Emit scope step-up challenges for incremental authorization (SEP-2350) by [@jlowin](https://github.com/jlowin) in [#4623](https://github.com/PrefectHQ/fastmcp/pull/4623) +* Honor OAuth application_type in DCR (SEP-837) by [@jlowin](https://github.com/jlowin) in [#4621](https://github.com/PrefectHQ/fastmcp/pull/4621) +* Drop stale label-noting instructions from CLAUDE.md by [@jlowin](https://github.com/jlowin) in [#4654](https://github.com/PrefectHQ/fastmcp/pull/4654) +* Add require_roles auth check by [@jlowin](https://github.com/jlowin) in [#4656](https://github.com/PrefectHQ/fastmcp/pull/4656) +* Add `valid_scopes` parameter to OIDC proxy valid scopes by [@Educg550](https://github.com/Educg550) in [#4660](https://github.com/PrefectHQ/fastmcp/pull/4660) +* feat: Add telemetry interop mode for FastMCP by [@strawgate](https://github.com/strawgate) in [#4046](https://github.com/PrefectHQ/fastmcp/pull/4046) +* Note that review comment threads should get an acknowledgement by [@jlowin](https://github.com/jlowin) in [#4678](https://github.com/PrefectHQ/fastmcp/pull/4678) +* Soften the review-comment reply guidance by [@jlowin](https://github.com/jlowin) in [#4683](https://github.com/PrefectHQ/fastmcp/pull/4683) +* Resolve review threads on fix, reply on decline by [@jlowin](https://github.com/jlowin) in [#4685](https://github.com/PrefectHQ/fastmcp/pull/4685) +* Move to the stable MCP Python SDK 2.0.0 by [@jlowin](https://github.com/jlowin) in [#4655](https://github.com/PrefectHQ/fastmcp/pull/4655) +### Security 🔒 +* Drive the FastMCP lifespan through the SDK session manager by [@jlowin](https://github.com/jlowin) in [#4446](https://github.com/PrefectHQ/fastmcp/pull/4446) +* Route skill file access through SDK path-security primitives by [@jlowin](https://github.com/jlowin) in [#4449](https://github.com/PrefectHQ/fastmcp/pull/4449) +* Screen templated resource parameters for path traversal by default by [@jlowin](https://github.com/jlowin) in [#4482](https://github.com/PrefectHQ/fastmcp/pull/4482) +* [codex] Add OAuthProxy RFC 9207 issuer responses by [@jlowin](https://github.com/jlowin) in [#4438](https://github.com/PrefectHQ/fastmcp/pull/4438) +* Apply app visibility where no host can by [@jlowin](https://github.com/jlowin) in [#4692](https://github.com/PrefectHQ/fastmcp/pull/4692) +### Fixes 🐞 +* Capture SharedContext for task-enabled Docket servers by [@jlowin](https://github.com/jlowin) in [#4443](https://github.com/PrefectHQ/fastmcp/pull/4443) +* Fix stale mcp.types imports in examples by [@jlowin](https://github.com/jlowin) in [#4452](https://github.com/PrefectHQ/fastmcp/pull/4452) +* Forward-port HTTP host guard compatibility by [@jlowin](https://github.com/jlowin) in [#4474](https://github.com/PrefectHQ/fastmcp/pull/4474) +* Fix Azure scope fallback by [@zzstoatzz](https://github.com/zzstoatzz) in [#4469](https://github.com/PrefectHQ/fastmcp/pull/4469) +* fix(server): omit ScalarElicitationType wrapper title from elicitation schemas by [@syf2211](https://github.com/syf2211) in [#4502](https://github.com/PrefectHQ/fastmcp/pull/4502) +* Skip unsupported JWKS keys instead of failing the whole key set (#4515) by [@earfman](https://github.com/earfman) in [#4517](https://github.com/PrefectHQ/fastmcp/pull/4517) +* Don't mutate the caller's schema in compress_schema by [@winklemad](https://github.com/winklemad) in [#4492](https://github.com/PrefectHQ/fastmcp/pull/4492) +* Forward upstream instructions through create_proxy by [@verdie-g](https://github.com/verdie-g) in [#4512](https://github.com/PrefectHQ/fastmcp/pull/4512) +* Serialize deep object query parameters by [@jlowin](https://github.com/jlowin) in [#4523](https://github.com/PrefectHQ/fastmcp/pull/4523) +* Reject positional-only tool parameters by [@jlowin](https://github.com/jlowin) in [#4524](https://github.com/PrefectHQ/fastmcp/pull/4524) +* Clarify PR-reopen flow and fix label-race that broke auto-reopen by [@jlowin](https://github.com/jlowin) in [#4518](https://github.com/PrefectHQ/fastmcp/pull/4518) +* Clean up disconnected task sessions by [@jlowin](https://github.com/jlowin) in [#4519](https://github.com/PrefectHQ/fastmcp/pull/4519) +* Handle expired OAuth client registrations by [@jlowin](https://github.com/jlowin) in [#4520](https://github.com/PrefectHQ/fastmcp/pull/4520) +* Fix OAuth request annotation after httpx2 migration by [@jlowin](https://github.com/jlowin) in [#4534](https://github.com/PrefectHQ/fastmcp/pull/4534) +* Fix docs banner contrast by [@jlowin](https://github.com/jlowin) in [#4522](https://github.com/PrefectHQ/fastmcp/pull/4522) +* Preserve component metadata in response cache by [@jlowin](https://github.com/jlowin) in [#4521](https://github.com/PrefectHQ/fastmcp/pull/4521) +* Clean up task sessions on connection exit by [@jlowin](https://github.com/jlowin) in [#4535](https://github.com/PrefectHQ/fastmcp/pull/4535) +* Include scopes in auth challenges by [@jlowin](https://github.com/jlowin) in [#4527](https://github.com/PrefectHQ/fastmcp/pull/4527) +* Make examples/ actually trigger the ty gate by [@jlowin](https://github.com/jlowin) in [#4541](https://github.com/PrefectHQ/fastmcp/pull/4541) +* Add subject field to AccessToken initialization by [@piaudonn](https://github.com/piaudonn) in [#4267](https://github.com/PrefectHQ/fastmcp/pull/4267) +* Restore Mintlify's fixed banner positioning by [@jlowin](https://github.com/jlowin) in [#4542](https://github.com/PrefectHQ/fastmcp/pull/4542) +* Fix #4292: SSRF guard breaks OAuth/JWKS fetches behind a corporate HTTP proxy by [@endofcake](https://github.com/endofcake) in [#4412](https://github.com/PrefectHQ/fastmcp/pull/4412) +* Preserve telemetry attributes when a sampler does not forward them by [@jlowin](https://github.com/jlowin) in [#4539](https://github.com/PrefectHQ/fastmcp/pull/4539) +* Speed up the unit test suite, and fix the task-notification race it surfaced by [@jlowin](https://github.com/jlowin) in [#4550](https://github.com/PrefectHQ/fastmcp/pull/4550) +* Fix label triage applying no labels, and make blocked tool calls fail by [@jlowin](https://github.com/jlowin) in [#4555](https://github.com/PrefectHQ/fastmcp/pull/4555) +* Fix AI workflow allowlists being destroyed by tokenization by [@jlowin](https://github.com/jlowin) in [#4560](https://github.com/PrefectHQ/fastmcp/pull/4560) +* Make transformed tool `required` order deterministic by [@Kludex](https://github.com/Kludex) in [#4564](https://github.com/PrefectHQ/fastmcp/pull/4564) +* Stop gather() from creating coroutines it may never schedule by [@jlowin](https://github.com/jlowin) in [#4559](https://github.com/PrefectHQ/fastmcp/pull/4559) +* Restore upgraded dependency checks by [@zzstoatzz](https://github.com/zzstoatzz) in [#4576](https://github.com/PrefectHQ/fastmcp/pull/4576) +* Fix skill frontmatter parsing with UTF-8 BOM by [@hxaxd](https://github.com/hxaxd) in [#4533](https://github.com/PrefectHQ/fastmcp/pull/4533) +* Fix File helper extension handling by [@VectorPeak](https://github.com/VectorPeak) in [#4531](https://github.com/PrefectHQ/fastmcp/pull/4531) +* Fix percent-encoded skill file names unreadable in resources mode by [@jlowin](https://github.com/jlowin) in [#4590](https://github.com/PrefectHQ/fastmcp/pull/4590) +* Fix flaky stdio crash-recovery tests by [@jlowin](https://github.com/jlowin) in [#4594](https://github.com/PrefectHQ/fastmcp/pull/4594) +* Bridge camelCase ToolAnnotations reads by [@zzstoatzz](https://github.com/zzstoatzz) in [#4597](https://github.com/PrefectHQ/fastmcp/pull/4597) +* Preserve raw CallToolResult tool returns by [@LarryHu0217](https://github.com/LarryHu0217) in [#4587](https://github.com/PrefectHQ/fastmcp/pull/4587) +* Advertise only supported token endpoint auth methods in OAuthProxy metadata by [@jlowin](https://github.com/jlowin) in [#4608](https://github.com/PrefectHQ/fastmcp/pull/4608) +* Fix OAuth proxy override typing by [@zzstoatzz](https://github.com/zzstoatzz) in [#4612](https://github.com/PrefectHQ/fastmcp/pull/4612) +* Pin burner-redis below the Windows-crashing 0.1.7 release by [@jlowin](https://github.com/jlowin) in [#4618](https://github.com/PrefectHQ/fastmcp/pull/4618) +* fix : canonical mime type mapping from formats to remove inconsistency #4627 by [@Aman071106](https://github.com/Aman071106) in [#4628](https://github.com/PrefectHQ/fastmcp/pull/4628) +* fix: accept callable roots handlers by [@ShuyingZhang](https://github.com/ShuyingZhang) in [#4639](https://github.com/PrefectHQ/fastmcp/pull/4639) +* Pass the MCP conformance suite's draft and pending scenarios by [@jlowin](https://github.com/jlowin) in [#4650](https://github.com/PrefectHQ/fastmcp/pull/4650) +* Use issuer_url for OAuth issuer identity by [@jlowin](https://github.com/jlowin) in [#4652](https://github.com/PrefectHQ/fastmcp/pull/4652) +* Fix the ty failure blocking upgrade checks on main by [@jlowin](https://github.com/jlowin) in [#4657](https://github.com/PrefectHQ/fastmcp/pull/4657) +* Bind CIMD assertion audience to the advertised token endpoint by [@jlowin](https://github.com/jlowin) in [#4659](https://github.com/PrefectHQ/fastmcp/pull/4659) +* Record effective scopes on the OAuth transaction by [@jlowin](https://github.com/jlowin) in [#4670](https://github.com/PrefectHQ/fastmcp/pull/4670) +* Copy schemas iteratively so deep nesting still compresses by [@jlowin](https://github.com/jlowin) in [#4671](https://github.com/PrefectHQ/fastmcp/pull/4671) +* Fix OpenAPI allOf reference fields by [@hxaxd](https://github.com/hxaxd) in [#4653](https://github.com/PrefectHQ/fastmcp/pull/4653) +* Flatten OpenAPI discriminator subtypes into request bodies by [@jlowin](https://github.com/jlowin) in [#4677](https://github.com/PrefectHQ/fastmcp/pull/4677) +* Let maintenance releases publish without fastmcp-tasks by [@jlowin](https://github.com/jlowin) in [#4676](https://github.com/PrefectHQ/fastmcp/pull/4676) +* Read CLI-scanned MCP config files as UTF-8 explicitly by [@jlowin](https://github.com/jlowin) in [#4690](https://github.com/PrefectHQ/fastmcp/pull/4690) +* Late-bind app tool names so UIs survive composition by [@jlowin](https://github.com/jlowin) in [#4682](https://github.com/PrefectHQ/fastmcp/pull/4682) +### Docs 📚 +* Docs: forward-port v3.4.4 changelog entries by [@jlowin](https://github.com/jlowin) in [#4476](https://github.com/PrefectHQ/fastmcp/pull/4476) +* Document icon theme support by [@jlowin](https://github.com/jlowin) in [#4537](https://github.com/PrefectHQ/fastmcp/pull/4537) +* Add missing 4.0.0 version badge to Path Security docs by [@jlowin](https://github.com/jlowin) in [#4540](https://github.com/PrefectHQ/fastmcp/pull/4540) +* Align server component docs by [@strawgate](https://github.com/strawgate) in [#4260](https://github.com/PrefectHQ/fastmcp/pull/4260) +* Align CLI, deployment, and config docs by [@strawgate](https://github.com/strawgate) in [#4259](https://github.com/PrefectHQ/fastmcp/pull/4259) +* Align client, Apps, and integration docs by [@strawgate](https://github.com/strawgate) in [#4261](https://github.com/PrefectHQ/fastmcp/pull/4261) +* Fix stale MRTR/elicitation framing in client and upgrade docs by [@jlowin](https://github.com/jlowin) in [#4551](https://github.com/PrefectHQ/fastmcp/pull/4551) +* docs: quote pip extras install examples by [@RachGranville](https://github.com/RachGranville) in [#4568](https://github.com/PrefectHQ/fastmcp/pull/4568) +* Document Windows CI parallelism and the subprocess_heavy marker by [@jlowin](https://github.com/jlowin) in [#4575](https://github.com/PrefectHQ/fastmcp/pull/4575) +* Document v3->v4 removals and add upgrade-reality tests by [@jlowin](https://github.com/jlowin) in [#4585](https://github.com/PrefectHQ/fastmcp/pull/4585) +* Archive v3 docs and publish v4 as the primary version by [@jlowin](https://github.com/jlowin) in [#4613](https://github.com/PrefectHQ/fastmcp/pull/4613) +* Document targeted v4 prerelease installation by [@zzstoatzz](https://github.com/zzstoatzz) in [#4598](https://github.com/PrefectHQ/fastmcp/pull/4598) +* Fix stale Mac/Windows-vs-Linux OAuth key/storage docs by [@jlowin](https://github.com/jlowin) in [#4617](https://github.com/PrefectHQ/fastmcp/pull/4617) +* v4 docs quality pass: stale task/era claims, broken links, polish by [@jlowin](https://github.com/jlowin) in [#4619](https://github.com/PrefectHQ/fastmcp/pull/4619) +* whats-new: add the argument completion capability by [@jlowin](https://github.com/jlowin) in [#4620](https://github.com/PrefectHQ/fastmcp/pull/4620) +* docs: fix ProxyProvider docstring example calling nonexistent with_namespace() by [@andrew-stelmach-fleet](https://github.com/andrew-stelmach-fleet) in [#4633](https://github.com/PrefectHQ/fastmcp/pull/4633) +* Unpublish v4 development notes; prep docs for beta 1 by [@jlowin](https://github.com/jlowin) in [#4644](https://github.com/PrefectHQ/fastmcp/pull/4644) +* Expand the FAQ for the v4 transition by [@jlowin](https://github.com/jlowin) in [#4649](https://github.com/PrefectHQ/fastmcp/pull/4649) +* Document the issuer_url identity change for upgraders by [@jlowin](https://github.com/jlowin) in [#4658](https://github.com/PrefectHQ/fastmcp/pull/4658) +* Cover require_roles in the v4 highlights by [@jlowin](https://github.com/jlowin) in [#4666](https://github.com/PrefectHQ/fastmcp/pull/4666) +* Fix FAQ: sampling/roots/elicitation legacy-mode advice, SessionProvider registration by [@jlowin](https://github.com/jlowin) in [#4672](https://github.com/PrefectHQ/fastmcp/pull/4672) +* Audit v4 docs: fix missing version badges, fill whats-new gaps by [@jlowin](https://github.com/jlowin) in [#4668](https://github.com/PrefectHQ/fastmcp/pull/4668) +* Docs: add v3.4.5 changelog entries to main by [@jlowin](https://github.com/jlowin) in [#4674](https://github.com/PrefectHQ/fastmcp/pull/4674) +* Split the SDK upgrade guides by SDK version by [@jlowin](https://github.com/jlowin) in [#4684](https://github.com/PrefectHQ/fastmcp/pull/4684) +### Dependencies 📦 +* chore(deps): bump mcp from 1.26.0 to 1.27.2 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4514](https://github.com/PrefectHQ/fastmcp/pull/4514) +* chore(deps): bump actions/setup-node from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4546](https://github.com/PrefectHQ/fastmcp/pull/4546) +* Bump actions/upload-artifact from 4 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4640](https://github.com/PrefectHQ/fastmcp/pull/4640) +* Bump actions/setup-python from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4641](https://github.com/PrefectHQ/fastmcp/pull/4641) +* chore(deps): bump mcp from 1.27.2 to 1.28.1 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4614](https://github.com/PrefectHQ/fastmcp/pull/4614) +### Other Changes 🦾 +* Test: HTTP lifespan fires once per process across sessions by [@jlowin](https://github.com/jlowin) in [#4470](https://github.com/PrefectHQ/fastmcp/pull/4470) +## New Contributors +* @syf2211 made their first contribution in [#4502](https://github.com/PrefectHQ/fastmcp/pull/4502) +* @earfman made their first contribution in [#4517](https://github.com/PrefectHQ/fastmcp/pull/4517) +* @winklemad made their first contribution in [#4492](https://github.com/PrefectHQ/fastmcp/pull/4492) +* @verdie-g made their first contribution in [#4512](https://github.com/PrefectHQ/fastmcp/pull/4512) +* @vijaydeepsinha made their first contribution in [#4411](https://github.com/PrefectHQ/fastmcp/pull/4411) +* @piaudonn made their first contribution in [#4267](https://github.com/PrefectHQ/fastmcp/pull/4267) +* @szepeviktor made their first contribution in [#4498](https://github.com/PrefectHQ/fastmcp/pull/4498) +* @endofcake made their first contribution in [#4412](https://github.com/PrefectHQ/fastmcp/pull/4412) +* @Kludex made their first contribution in [#4564](https://github.com/PrefectHQ/fastmcp/pull/4564) +* @RachGranville made their first contribution in [#4568](https://github.com/PrefectHQ/fastmcp/pull/4568) +* @hxaxd made their first contribution in [#4533](https://github.com/PrefectHQ/fastmcp/pull/4533) +* @VectorPeak made their first contribution in [#4531](https://github.com/PrefectHQ/fastmcp/pull/4531) +* @LarryHu0217 made their first contribution in [#4587](https://github.com/PrefectHQ/fastmcp/pull/4587) +* @andrew-stelmach-fleet made their first contribution in [#4633](https://github.com/PrefectHQ/fastmcp/pull/4633) +* @Aman071106 made their first contribution in [#4628](https://github.com/PrefectHQ/fastmcp/pull/4628) +* @ShuyingZhang made their first contribution in [#4639](https://github.com/PrefectHQ/fastmcp/pull/4639) +* @Educg550 made their first contribution in [#4660](https://github.com/PrefectHQ/fastmcp/pull/4660) + +**Full Changelog**: [v3.4.5...v4.0.0b1](https://github.com/PrefectHQ/fastmcp/compare/v3.4.5...v4.0.0b1) + + + + + +**[v3.4.5: Key Change](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.5)** + +FastMCP 3.4.5 collects five fixes for the 3.x line, led by `JWTVerifier` no longer rejecting every token when an authorization server publishes an unrecognized key type such as Ed25519. + +### Fixes 🐞 +* Backport #4517 to release/3.x: skip unsupported JWKS keys (#4515) by [@kakiii](https://github.com/kakiii) in [#4631](https://github.com/PrefectHQ/fastmcp/pull/4631) +* Backport #4469 to release/3.x: fix Azure scope fallback by [@jlowin](https://github.com/jlowin) in [#4662](https://github.com/PrefectHQ/fastmcp/pull/4662) +* Backport #4523 to release/3.x: serialize deep object query parameters by [@jlowin](https://github.com/jlowin) in [#4664](https://github.com/PrefectHQ/fastmcp/pull/4664) +* Backport #4564 to release/3.x: make transformed tool required order deterministic by [@jlowin](https://github.com/jlowin) in [#4665](https://github.com/PrefectHQ/fastmcp/pull/4665) +* Backport #4492 to release/3.x: don't mutate the caller's schema in compress_schema by [@jlowin](https://github.com/jlowin) in [#4663](https://github.com/PrefectHQ/fastmcp/pull/4663) + +## New Contributors +* @kakiii made their first contribution in [#4631](https://github.com/PrefectHQ/fastmcp/pull/4631) + +**Full Changelog**: [v3.4.4...v3.4.5](https://github.com/PrefectHQ/fastmcp/compare/v3.4.4...v3.4.5) + + + **[v3.4.4: Host in Translation](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.4)** diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 6040cf88e..092738e7c 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -185,16 +185,11 @@ Set `mode="legacy"` to force the initialize handshake. This behaves identically client = Client("https://example.com/mcp", mode="legacy") ``` -Legacy mode is also what you need for the capabilities that depend on a live session between client and server. The handshake opens a persistent back-channel the server can push requests down, and the modern era removed it. Pin `mode="legacy"` when your code relies on any of these: - -- **[Sampling](/clients/sampling)** — server-initiated LLM completion requests -- **[Roots](/clients/roots)** — server-initiated requests for the client's roots -- **[Elicitation](/clients/elicitation)** — server-initiated requests for user input, which modern connections replace with [input-required rounds](/clients/elicitation#input-required-rounds) -- `client.ping()` and `transport.get_session_id()` +Legacy mode is also what carries the *pushed* form of a server's requests. The handshake opens a persistent back-channel down which a server can send a sampling, roots, or elicitation request mid-call, and the modern era removed it. Your handlers are unaffected by that: a [sampling](/clients/sampling), [roots](/clients/roots), or [elicitation](/clients/elicitation) handler you register answers a modern server's [input-required rounds](/clients/elicitation#input-required-rounds) from the same registration. Pin `mode="legacy"` when you connect to a server that pushes, or when your code calls `client.ping()` or `transport.get_session_id()`, which need the session the modern era does not open. Conversely, [background tasks](/clients/tasks) are **modern-only**: the tasks capability is negotiated over `2026-07-28` connections, so `mode="legacy"` never triggers one and a task-enabled tool just runs synchronously. -A FastMCP server serves both eras, so a default client negotiates the modern one and these raise an era-specific error. Pinning the handshake restores them. +A FastMCP server serves both eras, so a default client negotiates the modern one and the session-dependent calls raise an era-specific error there. Pinning the handshake restores them. You can also pin a specific modern protocol version to adopt it directly, without a discovery probe: @@ -342,7 +337,7 @@ See [Prompts](/clients/prompts) for detailed documentation including argument se The client supports callback handlers for advanced server interactions. These let you respond to server-initiated requests and receive notifications. -Sampling, elicitation, and roots are all server-initiated, so they belong to the handshake era described under [protocol negotiation](#protocol-negotiation). A default client negotiates the newest era both peers share, where the server has no back-channel to push those requests down, so an example that exercises them pins `mode="legacy"`. Logging and progress arrive as notifications on the response stream and work in either era. +Sampling, elicitation, and roots are the requests a server makes of the client. A server reaches your handler by whichever route its [era](#protocol-negotiation) allows — pushed down the open session on the handshake, returned as an input-required result on the modern protocol — and both routes dispatch to the same handler, so one registration covers both. Logging and progress arrive as notifications on the response stream and work in either era. ```python from fastmcp import Client @@ -360,7 +355,6 @@ async def sampling_handler(messages, params, context): client = Client( "my_mcp_server.py", - mode="legacy", log_handler=log_handler, progress_handler=progress_handler, sampling_handler=sampling_handler, diff --git a/docs/clients/elicitation.mdx b/docs/clients/elicitation.mdx index bdd8eba15..73cc6fdb7 100644 --- a/docs/clients/elicitation.mdx +++ b/docs/clients/elicitation.mdx @@ -13,10 +13,8 @@ Use this when you need to respond to server requests for user input during tool Elicitation allows MCP servers to request structured input from users during operations. Instead of requiring all inputs upfront, servers can interactively ask for missing parameters, request clarification, or gather additional context. -Two routes reach that outcome, and the protocol version the client negotiates decides which one applies. On older versions the server pushes an elicitation request down to the client, over the connection the `initialize` handshake opens; that is the flow the next few sections describe. On `2026-07-28` and later the server instead returns a description of what it needs, and the client answers with a fresh call — see [input-required rounds](#input-required-rounds). You write the same `elicitation_handler` either way — FastMCP routes it to whichever mechanism the connection supports. - -**This page shows the older protocol's elicitation flow.** On protocol version `2026-07-28` the server instead returns a description of what it needs and the client answers with a new call — see [input-required rounds](#input-required-rounds). The same `elicitation_handler` serves both. Clients default to `mode="auto"`, so the examples below pass `mode="legacy"` to exercise the server-initiated flow. See [protocol negotiation](/clients/client#protocol-negotiation). +**These sections show the server-initiated flow, which the handshake-era protocol uses.** On `2026-07-28` the server asks by returning a request instead — see [input-required rounds](#input-required-rounds). One `elicitation_handler` serves both, so the examples below pin `mode="legacy"` only to exercise the pushed form. ## Handler Template diff --git a/docs/clients/notifications.mdx b/docs/clients/notifications.mdx index b9e8dc384..1864dd23b 100644 --- a/docs/clients/notifications.mdx +++ b/docs/clients/notifications.mdx @@ -47,7 +47,7 @@ For fine-grained targeting, subclass `MessageHandler` to use specific hooks: ```python from fastmcp import Client from fastmcp.client.messages import MessageHandler -import mcp_types +import mcp.types as mcp_types class MyMessageHandler(MessageHandler): async def on_tool_list_changed( @@ -78,7 +78,7 @@ client = Client( ```python from fastmcp.client.messages import MessageHandler -import mcp_types +import mcp.types as mcp_types class MyMessageHandler(MessageHandler): async def on_message(self, message) -> None: @@ -141,7 +141,7 @@ A practical example of maintaining a tool cache that refreshes when tools change ```python from fastmcp import Client from fastmcp.client.messages import MessageHandler -import mcp_types +import mcp.types as mcp_types class ToolCacheHandler(MessageHandler): def __init__(self): diff --git a/docs/clients/roots.mdx b/docs/clients/roots.mdx index cb50c0447..08f5c9786 100644 --- a/docs/clients/roots.mdx +++ b/docs/clients/roots.mdx @@ -1,7 +1,7 @@ --- title: Client Roots sidebarTitle: Roots -description: Provide local context and resource boundaries to MCP servers. +description: Tell servers which local paths your client can reach. icon: folder-tree --- @@ -11,29 +11,26 @@ import { VersionBadge } from '/snippets/version-badge.mdx' Use this when you need to tell servers what local resources the client has access to. -Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses. +A root is a path your client is willing to expose — a project directory, a workspace, a document store. Servers read them to scope their work, so a tool that searches files searches where you pointed it, and a server that gets no roots has to ask the user for paths instead. Roots describe where the client can reach; the server takes them as its working boundary. - -**Roots require the older MCP protocol.** A server reads roots by sending a request down to the client, and protocol version `2026-07-28` removed the server's ability to do that. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so the examples below pass `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation). - +Register them once with `roots=`, and the client answers however the server asks. A handshake-era server pushes a `roots/list` request down the open session and reads the reply mid-call; a modern (`2026-07-28`) server has no such channel, so it returns a roots request and `fastmcp.Client` fulfils it from the same registration and re-issues the call with the answer attached. The default `mode="auto"` negotiates whichever era the server speaks, so the examples below work on either — see [protocol negotiation](/clients/client#protocol-negotiation) for how that choice is made, and [the guard pattern](/servers/elicitation#sampling-and-roots) for how a server issues the modern form. ## Static Roots -Provide a list of roots when creating the client: +When the paths are known up front, pass them as a list. The client holds them for the life of the connection and hands back the same set every time a server asks. ```python from fastmcp import Client client = Client( "my_mcp_server.py", - mode="legacy", - roots=["/path/to/root1", "/path/to/root2"] + roots=["file:///path/to/root1", "file:///path/to/root2"] ) ``` ## Dynamic Roots -Use a callback to compute roots dynamically when the server requests them: +Pass a callback instead when the roots depend on something the client learns at runtime, such as the workspace the user has open. It runs at the moment a server asks, on either route, and receives the request context so you can see which request it is answering: ```python from fastmcp import Client @@ -41,11 +38,10 @@ from fastmcp.client.roots import RequestContext async def roots_callback(context: RequestContext) -> list[str]: print(f"Server requested roots (Request ID: {context.request_id})") - return ["/path/to/root1", "/path/to/root2"] + return ["file:///path/to/root1", "file:///path/to/root2"] client = Client( "my_mcp_server.py", - mode="legacy", roots=roots_callback ) ``` diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx index 1b83ae45d..f2dfe82e3 100644 --- a/docs/clients/sampling.mdx +++ b/docs/clients/sampling.mdx @@ -1,7 +1,7 @@ --- title: LLM Sampling sidebarTitle: Sampling -description: Handle server-initiated LLM completion requests. +description: Answer a server's request for an LLM completion. icon: robot --- @@ -9,57 +9,46 @@ import { VersionBadge } from "/snippets/version-badge.mdx"; -Use this when you need to respond to server requests for LLM completions. +Use this when a server asks your client to run an LLM completion on its behalf. -MCP servers can request LLM completions from clients during tool execution. This enables servers to delegate AI reasoning to the client, which controls which LLM is used and how requests are made. +Sampling is how a server borrows your model. Rather than hold an API key of its own, the server describes the messages it wants completed and asks you to run them — you pick the model, and you pay for the tokens. Your side of that arrangement is one function, a **sampling handler**, registered when you create the client. - -**Sampling requires the older MCP protocol.** A server requests sampling by sending a request down to the client, and protocol version `2026-07-28` removed the server's ability to do that. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so every example on this page passes `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation). - +The handler receives the conversation the server wants completed, the parameters it asked for, and a request context carrying metadata about the call. Return the generated text as a string and FastMCP wraps it in the protocol's result for you; return a `CreateMessageResult` yourself when you want to report the real model name or hand back content that isn't text. If the handler raises, the client sends the error back in place of a completion and the server's tool decides what to do about it. ## Handler Template ```python from fastmcp import Client from fastmcp.client.sampling import SamplingMessage, SamplingParams, RequestContext +from mcp.types import TextContent + async def sampling_handler( messages: list[SamplingMessage], params: SamplingParams, - context: RequestContext + context: RequestContext, ) -> str: - """ - Handle server requests for LLM completions. - - Args: - messages: Conversation messages to send to the LLM - params: Sampling parameters (temperature, max_tokens, etc.) - context: Request context with metadata - - Returns: - Generated text response from your LLM - """ - # Extract message content - conversation = [] - for message in messages: - content = message.content.text if hasattr(message.content, 'text') else str(message.content) - conversation.append(f"{message.role}: {content}") - - # Use the system prompt if provided + """Run the server's messages against your LLM and return the completion.""" + conversation = [ + f"{message.role}: {message.content.text}" + for message in messages + if isinstance(message.content, TextContent) + ] system_prompt = params.system_prompt or "You are a helpful assistant." - # Integrate with your LLM service here + # Call your LLM here with `conversation` and `system_prompt`. return "Generated response based on the messages" -client = Client( - "my_mcp_server.py", - mode="legacy", - sampling_handler=sampling_handler, -) + +client = Client("my_mcp_server.py", sampling_handler=sampling_handler) ``` +The client answers with this handler however the server asks for a completion. The default `mode="auto"` negotiates whichever protocol era the server speaks, and one handler covers both of the routes an era can use — see [Request Routes](#request-routes). + ## Handler Parameters +Everything the server sends arrives in the first two arguments. The messages are the conversation to complete; the parameters are how the server would like it completed. You decide how much of that to honor, since the client owns the model — a preference your provider cannot express is yours to ignore. + The role of the message @@ -71,11 +60,11 @@ client = Client( - + Optional system prompt the server wants to use - + Server preferences for model selection (hints, cost/speed/intelligence priorities) @@ -83,11 +72,11 @@ client = Client( Sampling temperature - + Maximum tokens to generate - + Stop sequences for sampling @@ -95,14 +84,14 @@ client = Client( Tools the LLM can use during sampling - + Tool usage behavior (`auto`, `required`, or `none`) ## Built-in Handlers -FastMCP provides built-in handlers for OpenAI, Anthropic, and Google Gemini APIs that support the full sampling API including tool use. +Writing the provider call yourself is rarely worth it. FastMCP ships handlers for OpenAI, Anthropic, and Google Gemini that implement the full sampling API, tool use included, and translate the protocol's parameters into each provider's own. Give one a default model and pass it where your own handler would go. Write a custom handler when you need routing across providers, caching, or a provider FastMCP does not cover. ### OpenAI Handler @@ -114,19 +103,19 @@ from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler client = Client( "my_mcp_server.py", - mode="legacy", sampling_handler=OpenAISamplingHandler(default_model="gpt-4o"), ) ``` -For OpenAI-compatible APIs (like local models): +Point the handler at any OpenAI-compatible API, including a local model server, by passing your own provider client: ```python +from fastmcp import Client +from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler from openai import AsyncOpenAI client = Client( "my_mcp_server.py", - mode="legacy", sampling_handler=OpenAISamplingHandler( default_model="llama-3.1-70b", client=AsyncOpenAI(base_url="http://localhost:8000/v1"), @@ -148,7 +137,6 @@ from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler client = Client( "my_mcp_server.py", - mode="legacy", sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"), ) ``` @@ -167,7 +155,6 @@ from fastmcp.client.sampling.handlers.google_genai import GoogleGenaiSamplingHan client = Client( "my_mcp_server.py", - mode="legacy", sampling_handler=GoogleGenaiSamplingHandler(default_model="gemini-2.0-flash"), ) ``` @@ -176,25 +163,32 @@ client = Client( Install the Google Gemini handler with `pip install 'fastmcp[gemini]'`. -## Sampling Capabilities +The [source of these handlers](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) is the best reference for writing your own. -When you provide a `sampling_handler`, FastMCP automatically advertises full sampling capabilities to the server, including tool support. To disable tool support for simpler handlers: +## Tool Use + +A sampling request can carry tools. When it does, your handler passes them to the model and returns whatever comes back, tool calls included — the server executes the tools itself and sends a follow-up sampling request with the results if it needs another turn. Your handler never runs a tool. + +Registering any `sampling_handler` advertises full sampling support, tools included. A handler that only generates text should say so, so servers know not to send tools it will drop: ```python -from mcp_types import SamplingCapability +from fastmcp import Client +from mcp.types import SamplingCapability + + +async def text_only_handler(messages, params, context) -> str: + return "Generated response based on the messages" + client = Client( "my_mcp_server.py", - mode="legacy", - sampling_handler=basic_handler, - sampling_capabilities=SamplingCapability(), # No tool support + sampling_handler=text_only_handler, + sampling_capabilities=SamplingCapability(), ) ``` -## Tool Execution +## Request Routes -Tool execution happens on the server side. The client's role is to pass tools to the LLM and return the LLM's response (which may include tool use requests). The server then executes the tools and may send follow-up sampling requests with tool results. +Servers reach your handler by two routes, and which one applies depends on the protocol era the connection negotiated. A handshake-era server pushes a `sampling/createMessage` request down the open session while a tool is running and waits for the reply. A modern (`2026-07-28`) connection has no such channel, so the tool ends its round by returning a request for a completion instead; the client answers from your handler and calls the tool again with the result attached. - -To implement a custom sampling handler, see the [handler source code](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) as a reference. - +One registration covers both, so this is rarely something you configure — it matters only when you pin an era, since `mode="legacy"` is the sole route that carries a pushed request. See [protocol negotiation](/clients/client#protocol-negotiation) for how the era is chosen, and [Sampling](/servers/sampling) under Servers for how a server issues these requests. diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx index a5dda9715..29d104d4e 100644 --- a/docs/deployment/http.mdx +++ b/docs/deployment/http.mdx @@ -149,6 +149,48 @@ export FASTMCP_HTTP_ALLOWED_ORIGINS='["https://app.example.com"]' Use `host_origin_protection="auto"` to protect localhost-bound direct servers while allowing ASGI, serverless, and reverse-proxy deployments to keep their existing Host handling unless they configure explicit trust rules. Use `host_origin_protection=False` to keep the request guard disabled. +### Gateway Routing Headers + + + +A gateway, load balancer, or reverse proxy in front of your MCP server often needs to route a request before it reads the JSON-RPC body — the body may be an SSE stream, or the gateway may simply want to avoid parsing it. On a connection that negotiates the modern `2026-07-28` protocol, Streamable HTTP clients built on the MCP Python SDK (including FastMCP's own client) attach routing information to each request as HTTP headers so an intermediary can dispatch on headers alone: + +- `Mcp-Method` carries the JSON-RPC method (for example `tools/call`) on every request. +- `Mcp-Name` carries the target's name on named operations — the tool name for `tools/call`, the prompt name for `prompts/get`, the resource URI for `resources/read`. +- `Mcp-Param-*` carries selected argument values for a `tools/call`, one header per opted-in parameter. + +FastMCP's HTTP transport neither strips nor rewrites these headers, so a gateway sees them exactly as the client sent them. The `Host`/`Origin` request guard inspects only `Host` and `Origin` and leaves the routing headers untouched. + + +These headers are a feature of the modern `2026-07-28` protocol. A client connected over an earlier protocol revision — including one running in legacy mode or one that has fallen back to a legacy server — sends no routing headers at all. Design gateway routing to require the headers rather than assume their presence: if a request arrives without them, fall back to inspecting the body or route it to a default backend, rather than dropping it. + + +To expose an argument as an `Mcp-Param-*` header, annotate the parameter with the `x-mcp-header` JSON Schema extension. FastMCP carries the annotation into the tool's advertised input schema, and a conforming client mirrors the argument into a header named `Mcp-Param-`: + +```python +from typing import Annotated + +from pydantic import Field + +from fastmcp import FastMCP + +mcp = FastMCP("My Server") + +@mcp.tool +def query_tenant( + tenant: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Tenant"})], + sql: str, +) -> str: + """A call to this tool sends the tenant value as an `Mcp-Param-Tenant` header.""" + ... +``` + +A gateway can now route on `Mcp-Param-Tenant` — for example, pinning each tenant to a dedicated backend — without inspecting the request body. The annotation is only permitted on `string`, `integer`, and `boolean` parameters. These headers advertise routing intent; treat them as untrusted hints, since the server still validates the request body as the source of truth. + + +When you put a FastMCP [proxy](/servers/proxy) in front of another server, the proxy re-advertises each backend tool's `x-mcp-header` annotation, so routing headers work across the proxy hop as well. The headers themselves are regenerated per hop rather than forwarded verbatim, since each describes a single HTTP request. + + ### Health Checks Health check endpoints are essential for monitoring your deployed server and ensuring it's responding correctly. FastMCP allows you to add custom routes alongside your MCP endpoints, making it easy to implement health checks that work with both deployment approaches. @@ -504,7 +546,7 @@ base_url="http://localhost:8000/api" # Includes mount prefix mcp_path="/mcp" # Internal MCP path, NOT the mount prefix ``` -**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`. +**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`. It sets the `issuer` advertised in the authorization server metadata and the `iss` on issued tokens, while the endpoints in that metadata continue to point at `base_url`. ```python # Usually not needed - just set base_url and it works @@ -658,7 +700,7 @@ When deploying FastMCP behind a load balancer or running multiple server instanc #### Understanding Sessions -By default, FastMCP's Streamable HTTP transport maintains server-side sessions. Sessions enable stateful MCP features like [elicitation](/servers/elicitation) and [sampling](/servers/sampling), where the server needs to maintain context across multiple requests from the same client. +By default, FastMCP's Streamable HTTP transport maintains server-side sessions. A session holds the context a server keeps across multiple requests from the same client, and it carries the handshake-era back-channel that server-initiated requests like [elicitation](/servers/elicitation) push down. This works perfectly for single-instance deployments. However, sessions are stored in memory on each server instance, which creates challenges when scaling horizontally. diff --git a/docs/docs.json b/docs/docs.json index 97dcdd572..417c30459 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -16,7 +16,7 @@ "dark": "#475569", "light": "#1e3a5f" }, - "content": "FastMCP 4 is in alpha — you're reading the v4 docs. [What's new](/getting-started/whats-new) · [FastMCP 3 docs](/v3/getting-started/welcome)" + "content": "FastMCP 4 is in beta — check out [what's new](/getting-started/whats-new)!" }, "colors": { "dark": "#f72585", @@ -358,10 +358,12 @@ "group": "Upgrading", "icon": "up", "pages": [ - "getting-started/upgrading/from-fastmcp-2", "getting-started/upgrading/from-fastmcp-3", - "getting-started/upgrading/from-mcp-sdk", - "getting-started/upgrading/from-low-level-sdk" + "getting-started/upgrading/from-fastmcp-2", + "getting-started/upgrading/from-mcp-sdk-v1", + "getting-started/upgrading/from-mcp-sdk-v2", + "getting-started/upgrading/from-low-level-sdk-v1", + "getting-started/upgrading/from-low-level-sdk-v2" ] }, { @@ -372,19 +374,7 @@ "development/contributing", "development/tests", "development/releases", - "patterns/contrib", - { - "collapsed": true, - "group": "v4 Notes", - "pages": [ - "development/v4-notes/index", - "development/v4-notes/change-register", - "development/v4-notes/feature-program", - "development/v4-notes/background-tasks", - "development/v4-notes/protocol-2026", - "development/v4-notes/known-gaps" - ] - } + "patterns/contrib" ] }, { @@ -413,7 +403,7 @@ "icon": "code" } ], - "version": "v4.0.0 (alpha 1)" + "version": "v4.0.0 (beta 1)" }, { "$ref": "./v3-navigation.json" @@ -424,6 +414,30 @@ ] }, "redirects": [ + { + "destination": "/getting-started/whats-new", + "source": "/development/v4-notes/index" + }, + { + "destination": "/getting-started/upgrading/from-fastmcp-3", + "source": "/development/v4-notes/change-register" + }, + { + "destination": "/getting-started/whats-new", + "source": "/development/v4-notes/feature-program" + }, + { + "destination": "/getting-started/whats-new", + "source": "/development/v4-notes/protocol-2026" + }, + { + "destination": "/getting-started/upgrading/from-fastmcp-3", + "source": "/development/v4-notes/known-gaps" + }, + { + "destination": "/servers/tasks", + "source": "/development/v4-notes/background-tasks" + }, { "destination": "/apps/fastmcp-app", "source": "/apps/interactive-apps" @@ -497,13 +511,21 @@ "source": "/development/upgrade-guide" }, { - "destination": "/getting-started/upgrading/from-mcp-sdk", + "destination": "/getting-started/upgrading/from-mcp-sdk-v1", "source": "/getting-started/upgrading-from-sdk" }, { - "destination": "/getting-started/upgrading/from-low-level-sdk", + "destination": "/getting-started/upgrading/from-mcp-sdk-v1", + "source": "/getting-started/upgrading/from-mcp-sdk" + }, + { + "destination": "/getting-started/upgrading/from-low-level-sdk-v1", "source": "/getting-started/low-level-sdk" }, + { + "destination": "/getting-started/upgrading/from-low-level-sdk-v1", + "source": "/getting-started/upgrading/from-low-level-sdk" + }, { "destination": "/getting-started/upgrading/from-fastmcp-3", "source": "/getting-started/upgrading/to-mcp-sdk-v2" diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 4dae8e9b7..9bfbdbcb1 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -68,13 +68,17 @@ See the [Upgrade Guide](/getting-started/upgrading/from-fastmcp-2) for a complet ### From the MCP SDK -#### From FastMCP 1.0 +Which guide you want depends on which `mcp` version you're on and which of its two server APIs you used. -If you're using FastMCP 1.0 via the `mcp` package (meaning you import FastMCP as `from mcp.server.fastmcp import FastMCP`), upgrading is straightforward — for most servers, it's a single import change. See the [full upgrade guide](/getting-started/upgrading/from-mcp-sdk) for details. +#### From the high-level server -#### From the Low-Level Server API +If you're using FastMCP 1.0 via SDK v1 (meaning you import FastMCP as `from mcp.server.fastmcp import FastMCP`), upgrading is straightforward — for most servers it's a single import change. See [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1), which also explains why that route is usually easier than moving to MCP SDK v2. -If you built your server directly on the `mcp` package's `Server` class — with `list_tools()`/`call_tool()` handlers and hand-written JSON Schema — see the [migration guide](/getting-started/upgrading/from-low-level-sdk) for a full walkthrough. +If you already moved to SDK v2 and write against `MCPServer`, see [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2) — that migration is mostly renaming. + +#### From the low-level server + +If you built your server directly on the `mcp` package's `Server` class, the guide you want depends on how its handlers are registered. Decorators like `@server.list_tools()` mean SDK v1 — see [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1). Handlers passed to the constructor as `on_list_tools=` mean SDK v2 — see [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2). ## Troubleshooting diff --git a/docs/getting-started/upgrading/from-fastmcp-2.mdx b/docs/getting-started/upgrading/from-fastmcp-2.mdx index 8550413bc..98f0b4883 100644 --- a/docs/getting-started/upgrading/from-fastmcp-2.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-2.mdx @@ -1,11 +1,15 @@ --- title: Upgrading from FastMCP 2 sidebarTitle: "From FastMCP 2" -description: Migration instructions for upgrading between FastMCP versions +description: What changed in FastMCP 3 for servers written against FastMCP 2 icon: up --- -This guide covers breaking changes and migration steps when upgrading FastMCP. +This guide covers the breaking changes a FastMCP 2 server meets on its way to FastMCP 3, newest release first. + + +**Going all the way to FastMCP 4?** You need this page and [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3), in that order. The two describe different transitions: this one covers the v3 API changes, while the FastMCP 3 guide covers the MCP Python SDK v2 rebuild underneath v4. Where a v3 deprecation was later removed outright, this page marks it **Removed in v4**. + ## v3.0.0 @@ -101,7 +105,7 @@ For each issue found, show the original line, explain why it breaks, and provide In v2, you could configure transport settings directly in the `FastMCP()` constructor. In v3, `FastMCP()` is purely about your server's identity and behavior — transport configuration happens when you actually start serving. Passing any of the old kwargs now raises `TypeError` with a migration hint. -```python +```python test="skip" # Before mcp = FastMCP("server", host="0.0.0.0", port=8080) mcp.run() @@ -140,7 +144,7 @@ Keeping `DiskStore` requires `pip install 'py-key-value-aio[disk]'`, which re-in In v2, you could enable or disable individual components by calling methods on the component object itself. In v3, visibility is controlled through the server (or provider), which lets you target components by name, tag, or type without needing a reference to the object: -```python +```python test="skip" # Before tool = await server.get_tool("my_tool") tool.disable() @@ -155,7 +159,7 @@ Calling `.enable()` or `.disable()` on a component object now raises `NotImpleme The `get_tools()`, `get_resources()`, `get_prompts()`, and `get_resource_templates()` methods have been renamed to `list_tools()`, `list_resources()`, `list_prompts()`, and `list_resource_templates()`. More importantly, they now return lists instead of dicts — so code that indexes by name needs to change: -```python +```python test="skip" # Before tools = await server.get_tools() tool = tools["my_tool"] @@ -169,7 +173,7 @@ tool = next((t for t in tools if t.name == "my_tool"), None) Prompt functions now use FastMCP's `Message` class instead of `mcp.types.PromptMessage`. The new class is simpler — it accepts a plain string and defaults to `role="user"`, so most prompts become one-liners: -```python +```python test="skip" # Before from mcp.types import PromptMessage, TextContent @@ -187,7 +191,7 @@ def my_prompt() -> Message: If your prompt functions return raw dicts with `role` and `content` keys, those also need to change. v2 silently coerced dicts into prompt messages, but v3 requires typed `Message` objects (or plain strings for single user messages): -```python +```python test="skip" # Before (v2 accepted this) @mcp.prompt def my_prompt(): @@ -211,7 +215,7 @@ def my_prompt() -> list[Message]: `ctx.set_state()` and `ctx.get_state()` are now async because state in v3 is session-scoped and backed by a pluggable storage backend (rather than a simple dict). This means state persists across multiple tool calls within the same session: -```python +```python test="skip" # Before ctx.set_state("key", "value") value = ctx.get_state("key") @@ -223,7 +227,7 @@ value = await ctx.get_state("key") State values must also be JSON-serializable by default (dicts, lists, strings, numbers, etc.). If you need to store non-serializable values like an HTTP client, pass `serializable=False` — these values are request-scoped and only available during the current tool call: -```python +```python test="skip" await ctx.set_state("client", my_http_client, serializable=False) ``` @@ -245,7 +249,7 @@ parent.mount(child, namespace="child") In v2, auth providers like `GitHubProvider` could auto-load configuration from environment variables with a `FASTMCP_SERVER_AUTH_*` prefix. This magic has been removed — pass values explicitly: -```python +```python test="skip" # Before (v2) — client_id and client_secret loaded automatically # from FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID, etc. auth = GitHubProvider() @@ -278,7 +282,7 @@ transport = StreamableHttpTransport("http://localhost:8000/mcp") `OpenAPIProvider` no longer accepts a `timeout` parameter. Configure timeout on the httpx2 client directly. The `client` parameter is also now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout: -```python +```python test="skip" # Before provider = OpenAPIProvider(spec, client, timeout=60) @@ -291,7 +295,7 @@ provider = OpenAPIProvider(spec, client) The FastMCP metadata key in component `meta` dicts changed from `_fastmcp` to `fastmcp`. If you read metadata from tool or resource objects, update the key: -```python +```python test="skip" # Before tags = tool.meta.get("_fastmcp", {}).get("tags", []) @@ -309,7 +313,7 @@ Metadata is now always included — the `include_fastmcp_meta` parameter has bee In v2, `@mcp.tool` transformed your function into a `FunctionTool` object. In v3, decorators return your original function unchanged — which means decorated functions stay callable for testing, reuse, and composition: -```python +```python test="skip" @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" @@ -335,7 +339,7 @@ These were deprecated in v3. Items marked **Removed in v4** no longer work at al **mount() prefix → namespace** (Removed in v4) -```python +```python test="skip" # Removed in v4 main.mount(subserver, prefix="api") @@ -345,7 +349,7 @@ main.mount(subserver, namespace="api") **import_server() → mount()** (Removed in v4) -```python +```python test="skip" # Removed in v4 main.import_server(subserver) @@ -382,7 +386,7 @@ server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)]) **add_tool_transformation() → add_transform()** (Removed in v4) -```python +```python test="skip" # Removed in v4 mcp.add_tool_transformation("name", config) @@ -395,7 +399,7 @@ mcp.add_transform(ToolTransform({"name": config})) The proxy target is passed positionally in both APIs, so most calls migrate unchanged. If you passed the target by keyword, note that the parameter was renamed from `backend=` to `target=`. -```python +```python test="skip" # Removed in v4 proxy = FastMCP.as_proxy("http://example.com/mcp") proxy = FastMCP.as_proxy(backend="http://example.com/mcp") # keyword form @@ -424,12 +428,18 @@ server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)]) ### Removed Deprecated Features -- `BearerAuthProvider` → use `JWTVerifier` -- `Context.get_http_request()` → use `get_http_request()` from dependencies -- `from fastmcp import Image` → use `from fastmcp.utilities.types import Image` -- `FastMCP(dependencies=[...])` → use `fastmcp.json` configuration -- `FastMCPProxy(client=...)` → use `client_factory=lambda: ...` -- `output_schema=False` → use `output_schema=None` +A batch of long-deprecated surfaces came out in 2.14. Each fails loudly at import or call time, and each has a direct replacement: + +| Removed | Replacement | +|---|---| +| `BearerAuthProvider` | `JWTVerifier` — the same JWT validation under a name that says what it does | +| `Context.get_http_request()` | `get_http_request()` from [dependency injection](/servers/dependency-injection) | +| `from fastmcp import Image` | `from fastmcp.utilities.types import Image` | +| `FastMCP(dependencies=[...])` | a [`fastmcp.json`](/deployment/server-configuration) configuration file | +| `FastMCPProxy(client=...)` | `client_factory=lambda: ...` | +| `output_schema=False` | `output_schema=None` | + +Two of these are worth understanding rather than just swapping. `FastMCPProxy` takes a factory instead of a client because a single shared client cannot serve concurrent proxied sessions safely — the factory gives each session its own backend connection. And `output_schema=False` became `output_schema=None` because `False` read as "this tool has a schema, and it is false"; `None` says plainly that there is no schema. ## v2.13.0 @@ -437,7 +447,7 @@ server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)]) The OAuth proxy now issues its own JWT tokens. For production, provide explicit keys: -```python +```python test="skip" auth = GitHubProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx index 797632960..37ff33f62 100644 --- a/docs/getting-started/upgrading/from-fastmcp-3.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx @@ -1,52 +1,121 @@ --- title: Upgrading from FastMCP 3 -sidebarTitle: "From FastMCP 3.x" +sidebarTitle: "From FastMCP 3" description: What changes when you upgrade to FastMCP 4, which builds on the MCP Python SDK v2 icon: up --- -FastMCP 4 builds on the MCP Python SDK v2, and that is the source of every change in this guide. The SDK v2 makes two sweeping changes to the protocol layer: it splits the protocol types out of `mcp.types` into a standalone `mcp_types` package, and it renames every protocol field from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`, and so on). +FastMCP 4 builds on the MCP Python SDK v2, and that is the source of every change in this guide. The SDK v2 makes two sweeping changes to the protocol layer: it moves the protocol types into a standalone `mcp_types` package (still importable as `mcp.types`), and it renames every model field from camelCase to snake_case in Python (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`, and so on). The wire format does not change: the models keep their camelCase aliases and serialize under them, so this renames the attributes your code reads, not the JSON on the connection. -FastMCP 4 absorbs almost all of this for you. Field access is bridged so your existing reads keep working, and the imports you were taught have a stable home in FastMCP itself. The sections below describe what FastMCP handles for you, the small number of changes you must make in your own code, and the deprecation timeline for the compatibility shims. +FastMCP 4 absorbs almost all of this for you. Field access is bridged so your existing reads keep working, and the imports you were taught have a stable home in FastMCP itself. What the SDK cannot hide is the protocol's own direction: the new sessionless era removes the server's ability to call back into a client mid-request, and background tasks moved out of the core spec into an extension. Those two shape the changes a working server is most likely to feel. -## Install the v4 prerelease +The sections below cover what FastMCP handles for you, the changes you must make in your own code, the surfaces removed outright in 4.0, the behavior shifts that compile fine but act differently, and the deprecation timeline for the compatibility shims. -While FastMCP 4 is in prerelease, pin the alpha and its prerelease protocol dependencies explicitly. For a uv project, add the following to `pyproject.toml`: +## Install the v4 Prerelease + +While FastMCP 4 is in prerelease, pin the beta explicitly. The `fastmcp` package is a thin wrapper that depends on `fastmcp-slim` at the same version, so asking for a prerelease of one means asking for a prerelease of the other. pip infers that on its own: + +```bash +pip install "fastmcp==4.0.0b1" +``` + +uv is stricter: it allows prereleases only for packages you name, and `fastmcp-slim` arrives transitively. Constrain it alongside the requirement in `pyproject.toml`: ```toml [project] -dependencies = ["fastmcp==4.0.0a1"] +dependencies = ["fastmcp==4.0.0b1"] [tool.uv] -constraint-dependencies = [ - "fastmcp-slim==4.0.0a1", - "mcp==2.0.0b2", - "mcp-types==2.0.0b2", -] +constraint-dependencies = ["fastmcp-slim==4.0.0b1"] ``` -Then run `uv lock` or `uv sync` normally. The constraints opt only these transitive packages into their prerelease versions; you do not need `--prerelease allow`, which permits prereleases throughout the dependency graph. +Then run `uv lock` or `uv sync` normally. Naming the one package keeps the rest of your graph on stable releases, where `--prerelease allow` would opt every dependency into prereleases. The MCP SDK needs no constraint at all now that it ships stable releases — pinning `mcp==2.0.0b2` here would in fact break the resolution, since a prerelease does not satisfy FastMCP's own `mcp>=2.0.0` requirement. -## Environment requirements + +You are upgrading an MCP server or client from FastMCP 3.x to FastMCP 4, which is built on the MCP Python SDK v2. + +FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3 — it explains every item below, with the replacement code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs. + +Then search the provided code for each signal below. Most FastMCP 3 servers upgrade untouched, so report only what you actually find. + +ENVIRONMENT +- a pydantic pin below 2.12 +- a FastAPI pin below 0.133.0, the first release admitting Starlette 1.x (earlier ones cap it, e.g. 0.115.12 requires `starlette<0.47.0`), or any direct Starlette pin below 1.0.1 + +IMPORTS THAT NO LONGER RESOLVE +- `fastmcp.server.proxy`, `fastmcp.server.openapi`, `FastMCPOpenAPI` +- `fastmcp.experimental.server.openapi`, `fastmcp.experimental.utilities.openapi` +- `fastmcp.experimental.sampling.handlers` +- `fastmcp.server.apps`, `fastmcp.server.app` +- `fastmcp.tools.tool`, `fastmcp.resources.resource`, `fastmcp.prompts.prompt` +- `fastmcp.server.tasks`, `fastmcp.server.sampling` +- `fastmcp.server.auth.authorization` +- `CurrentDocket` or `CurrentWorker` from `fastmcp.dependencies` +- `SkillsProvider` +- `CachableToolResult`, `CachablePromptResult`, and their siblings (the misspelling was corrected with no alias) +- `PromptToolMiddleware`, `ResourceToolMiddleware` + +REMOVED SERVER METHODS AND KEYWORDS +- `FastMCP.as_proxy(...)` +- `import_server(...)` ← flag this one loudly: `mount()` is the replacement but NOT an equivalent. `import_server` took a static snapshot and skipped the child's lifespan and middleware; `mount` is a live composition that runs both. +- `mount(prefix=...)`, `mount(as_proxy=...)` +- `add_tool_transformation(...)`, `remove_tool_transformation(...)` +- `remove_tool(...)` ← its replacement raises KeyError where this raised NotFoundError, so check surrounding except clauses +- tool `serializer=`, tool `exclude_args=` +- `StreamableHttpTransport(sse_read_timeout=...)` +- `FASTMCP_DECORATOR_MODE` / `settings.decorator_mode` +- `FastMCP(sampling_handler=...)`, `sampling_handler_behavior=` + +REMOVED CONTEXT METHODS +- `ctx.sample(...)`, `ctx.sample_step(...)`, `ctx.list_roots(...)` +- Note for the user: if borrowing the CALLER's model is the whole point of the server, the guide's recommendation is to stay on FastMCP 3.x rather than migrate. +- The client side is NOT affected — `Client(sampling_handler=...)` and `Client(roots=...)` still mean what they meant. + +RUNTIME BREAKS THAT STILL COMPILE — the ones most likely to reach production +- `ctx.elicit(...)` anywhere. It is era-gated in 4.0 and raises on modern connections, which is what `Client` now negotiates by default. This is the single most likely runtime failure. +- `ctx.elicit(...)` called without `response_type` +- `except httpx.` around any FastMCP call. FastMCP raises httpx2 exceptions now, but httpx is usually still installed transitively, so the handler imports, type-checks, and silently never matches. +- a custom `httpx.AsyncClient`, `httpx_client_factory=`, or `httpx.Auth` handed to a FastMCP transport, `OAuth`, or `from_openapi` +- `Middleware.on_initialize` hooks, and `ctx.set_state` values read back in a later call — neither survives a modern connection +- middleware assuming `on_message` only sees routable requests +- camelCase field reads (`inputSchema`, `isError`, `mimeType`, `nextCursor`, `structuredContent`, `serverInfo`, and the rest) — these still work but warn, and are scheduled for removal +- clients matching on the resource-not-found error code -32002 +- templated resources whose parameters legitimately carry `..` or absolute paths +- an OAuth server (`OAuthProxy` or anything built on it) with `issuer_url` set to something other than `base_url` — this forces a one-time re-authorization of every client + +BACKGROUND TASKS +- `@mcp.tool(task=True)` or `TaskConfig` without `mcp.add_extension(TasksExtension())` +- `task=` on a `@mcp.resource` or `@mcp.prompt` decorator (tools only now) +- `client.call_tool(..., task=True)`, `read_resource(task=True)`, `get_prompt(task=True)` + +ERRORS +- `McpError(ErrorData(...))` positional construction. Catching and `err.error.code` are unchanged; only construction moved. + +For each item found, show the original line, name what changed, and give the corrected code from the guide. Where you could not confirm a replacement in the docs, say so instead of guessing. + + +## Environment Requirements The SDK v2 raises FastMCP's dependency floors, which matters before any of your code runs. **pydantic >= 2.12 is now the floor.** If your project pins an older pydantic (for example `pydantic==2.11.*`), installing this FastMCP release fails with an unsatisfiable-resolution error from your installer — bump your pin to `>=2.12` first. If you don't pin pydantic at all, installers upgrade it silently as part of the FastMCP upgrade. -**The server extra floors Starlette >= 1.0.1.** Modern FastAPI (0.11x and later) already runs on Starlette 1.x, so mounting a FastMCP server inside a FastAPI app coexists cleanly — verified with FastAPI 0.138.2. Only very old FastAPI versions pinned below Starlette 1.0.1 conflict; upgrade FastAPI if your resolver complains about Starlette. +**The server extra floors Starlette >= 1.0.1.** This is the requirement most likely to force an unrelated upgrade, because FastAPI pinned Starlette to a sub-1.0 range for a long time — FastAPI 0.115.12, for example, requires `starlette<0.47.0`. **FastAPI 0.133.0 is the first release that admits Starlette 1.x**, so a project pinned below that gets an unsatisfiable resolution rather than a version bump. Raise your FastAPI pin to `>=0.133.0` before upgrading FastMCP. Mounting a FastMCP server inside a FastAPI app is otherwise unaffected — verified against FastAPI 0.135.2 on Starlette 1.3.1. -## What FastMCP absorbs +## What FastMCP Absorbs -### Legacy camelCase field access keeps working +### camelCase Field Access Objects that FastMCP hands back to you — the results of `client.list_tools()`, `client.call_tool_mcp()`, `client.read_resource()`, and the parameter objects passed to your sampling and elicitation handlers — are SDK v2 objects with snake_case fields. FastMCP installs a compatibility bridge at import time that routes the old camelCase names to their new snake_case fields, so code written against FastMCP 2.x still reads correctly: ```python from fastmcp import Client -async with Client("my_mcp_server.py") as client: - tools = await client.list_tools() - schema = tools[0].inputSchema # still works, warns once + +async def read_schema(): + async with Client("my_mcp_server.py") as client: + tools = await client.list_tools() + return tools[0].inputSchema # still works, warns once ``` Each bridged read emits a `FastMCPDeprecationWarning` pointing you at the snake_case name (`tools[0].input_schema` here). The bridge covers the fields users actually read: `inputSchema`/`outputSchema` on tools; `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` on tool annotations; `mimeType` on resources and content; `isError`/`structuredContent` on tool results; `nextCursor` on paginated results; `serverInfo`/`protocolVersion` on the initialize result; the sampling parameter fields (`systemPrompt`, `maxTokens`, `stopSequences`, `modelPreferences`, `toolChoice`); and `requestedSchema` on elicitation parameters. @@ -61,17 +130,19 @@ fastmcp.settings.mcp_camelcase_compat = False See [Settings](/more/settings) for the full reference. -### Protocol types moved to `mcp_types` +### Protocol Types -The `mcp.types` module no longer exists. Every protocol type — `TextContent`, `ImageContent`, `Tool`, `ErrorData`, `Icon`, `PromptMessage`, `SamplingMessage`, `ToolAnnotations`, notification and request wrapper types like `ToolListChangedNotification`, and everything else — now lives in the standalone `mcp_types` package. Update your imports to point there: +Every protocol type — `TextContent`, `ImageContent`, `Tool`, `ErrorData`, `Icon`, `PromptMessage`, `SamplingMessage`, `ToolAnnotations`, notification and request wrapper types like `ToolListChangedNotification`, and everything else — now lives in a standalone `mcp_types` package. The SDK re-exports that package as `mcp.types`, so existing imports keep working and stay the preferred spelling: ```python -from mcp_types import TextContent, Tool, ToolAnnotations +from mcp.types import TextContent, Tool, ToolAnnotations ``` +Both names resolve to the same objects, so `from mcp_types import X` is equally valid — useful if you depend on the types without the rest of the SDK. What did change is the fields on those types: they are snake_case now (`input_schema`, not `inputSchema`), which the [compatibility bridge](#legacy-camelcase-field-access-keeps-working) covers for the objects FastMCP hands you. + `fastmcp.types` still exists, but holds only types FastMCP defines itself (currently just `Textarea`, used to render a multiline textarea in form-based UIs) — it does not re-export protocol types. -### `McpError` has an alias +### The `McpError` Alias `fastmcp.exceptions.McpError` is an alias of the SDK's `MCPError`. Catching errors is unchanged — `except McpError` still catches SDK-raised errors, and reading `err.error.code` still works: @@ -84,7 +155,7 @@ except McpError as err: print(err.error.code) ``` -### Behavior preserved across the SDK boundary +### Preserved Behavior A few client behaviors that touch the SDK are preserved so you don't have to change anything: @@ -92,17 +163,9 @@ A few client behaviors that touch the SDK are preserved so you don't have to cha - `client.ping()` returns a `bool`. - `client.transport.get_session_id()` returns `None` on protocol eras that have no session, rather than raising. (The SDK v2 removed session-id access from its streamable HTTP transport; FastMCP reconstructs it on the transport object.) -## What you must change +## What You Must Change -Everything above, FastMCP handled for you. What remains lives in your own code, where FastMCP can't reach it — your imports, how you construct errors, the custom HTTP clients you hand to a transport, and any place you reach past FastMCP's surfaces into the raw SDK objects. Each surfaces as a clear failure at import or call time, and each is a mechanical fix. - -**Your own `mcp.types` imports.** FastMCP can re-export types, but it can't rewrite imports in your code. Any `from mcp.types import X` or `import mcp.types` in your server or client fails at import time with: - -``` -ModuleNotFoundError: No module named 'mcp.types' -``` - -The raw message gives no hint toward the fix, so if you see it after upgrading, this is why. Switch to `from mcp_types import X`. +Everything above, FastMCP handled for you. What remains lives in your own code, where FastMCP can't reach it — how you construct errors, the custom HTTP clients you hand to a transport, and any place you reach past FastMCP's surfaces into the raw SDK objects. Each surfaces as a clear failure at import or call time, and each is a mechanical fix. **`McpError` construction.** The v1 pattern of wrapping an `ErrorData` and passing it positionally fails under SDK v2 with: @@ -112,7 +175,7 @@ TypeError: MCPError.__init__() missing 1 required positional argument: 'message' Note the message prints the class as `MCPError` (uppercase) even though your code wrote `McpError` — the old name is an alias for the SDK's renamed class. Construct the error with keyword arguments instead: -```python +```python test="skip" from fastmcp.exceptions import McpError # Before (raises TypeError under SDK v2): @@ -128,7 +191,7 @@ Catching and `err.error.code` are unchanged — only construction moved. **FastMCP now uses httpx2 exclusively.** FastMCP has replaced `httpx` with [httpx2](https://pypi.org/project/httpx2/), a next-generation httpx fork, across its entire HTTP stack — client transports and every server-side path (auth providers, the OpenAPI integration, the version check). `httpx` is no longer a FastMCP dependency. If you pass a custom client or factory into a FastMCP client transport — `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, `OAuth(httpx_client_factory=...)`, or a custom `httpx.Auth` as `Client(auth=...)` — those objects must now be httpx2. httpx2 is a drop-in fork with the same public API, so the change is an import swap: -```python +```python test="skip" # Before import httpx @@ -153,10 +216,12 @@ The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvide ```python import httpx # still installed transitively — this import works -try: - result = await client.call_tool("fetch", {"url": url}) -except httpx.ConnectError: # dead code: FastMCP now raises httpx2.ConnectError - return fallback() + +async def fetch(client, url): + try: + return await client.call_tool("fetch", {"url": url}) + except httpx.ConnectError: # dead code: FastMCP now raises httpx2.ConnectError + return fallback() ``` Grep your codebase for `except httpx.` and move those handlers to `httpx2`. The exception hierarchies match name-for-name, so the fix is an import swap — the hard part is remembering to look. One place you are covered automatically: exceptions raised *inside your tools and resources* (for example, a tool whose own old-httpx call gets a 429) are still mapped to `ToolError`/`ResourceError` by FastMCP's error boundary, which recognizes both libraries' exceptions during the transition. @@ -167,7 +232,7 @@ Two runtime behaviors shift with httpx2, and because the switch is now wholesale Deprecations that warned throughout the 3.x line are removed in 4.0. Unlike the bridged changes above, these fail immediately at the call site — a `ModuleNotFoundError`, `ImportError`, `AttributeError`, or `TypeError` — so nothing degrades silently. Every one has a direct replacement, and the fix is mechanical. -### Moved imports +### Moved Imports The proxy, OpenAPI, and app integrations moved to their permanent homes, and the internal component classes are no longer re-exported from their old aliases: @@ -178,13 +243,23 @@ The proxy, OpenAPI, and app integrations moved to their permanent homes, and the | `fastmcp.experimental.server.openapi` | `fastmcp.server.providers.openapi` | | `fastmcp.experimental.utilities.openapi` | `fastmcp.utilities.openapi` | | `fastmcp.server.apps`, `fastmcp.server.app` | `fastmcp.apps` (e.g. `AppConfig`) or `fastmcp` (`FastMCPApp`) | +| `Tool` / `ToolResult` from `fastmcp.tools.tool` | `fastmcp.tools` | +| `Resource` from `fastmcp.resources.resource` | `fastmcp.resources` | +| `Prompt` / `Message` from `fastmcp.prompts.prompt` | `fastmcp.prompts` | | `FunctionTool` / `ParsedFunction` / `tool` from `fastmcp.tools.tool` | `fastmcp.tools.function_tool` | | `FunctionResource` / `resource` from `fastmcp.resources.resource` | `fastmcp.resources.function_resource` | | `FunctionPrompt` / `prompt` from `fastmcp.prompts.prompt` | `fastmcp.prompts.function_prompt` | +| `OpenAISamplingHandler` from `fastmcp.experimental.sampling.handlers` | `fastmcp.client.sampling.handlers.openai` | +| `AuthCheck` / `AuthContext` / `require_scopes` / `require_roles` / `restrict_tag` / `run_auth_checks` from `fastmcp.server.auth.authorization` | `fastmcp.server.auth` | +| `run_auth_checks_with_shortfall` / `scope_requirements` from `fastmcp.server.auth.authorization` | `fastmcp.utilities.authorization` | +| `SkillsProvider` | `SkillsDirectoryProvider` from `fastmcp.server.providers.skills` | +| `TaskConfig` from `fastmcp.server.tasks` | `fastmcp.utilities.tasks` | +| `CurrentDocket` / `CurrentWorker` from `fastmcp.dependencies` | `fastmcp_tasks.dependencies` | +| `fastmcp.server.sampling` (and `SamplingTool`) | removed with [server-side sampling](#protocol-version-support) | Two renames in the same family are worth calling out because they have no compatibility alias. The response-caching wrapper models lost a spelling typo — `CachableToolResult`, `CachablePromptResult`, and their siblings became `CacheableToolResult`, `CacheablePromptResult`, etc. — so an import of the old spelling from `fastmcp.server.middleware.caching` raises `ImportError`. And `PromptToolMiddleware` / `ResourceToolMiddleware` are gone in favor of the `PromptsAsTools` / `ResourcesAsTools` transforms from `fastmcp.server.transforms` (the `ToolInjectionMiddleware` base class is retained). -### Removed server methods and `mount()` keywords +### Removed Server Methods These `FastMCP` methods and keywords have warned since 3.0 and are now removed: @@ -204,72 +279,177 @@ Two of these replacements are not exact behavioral swaps. `create_proxy` takes i `import_server` → `mount` is the one row here that is not a mechanical swap, because the two never had the same semantics. `import_server` took a **one-time static snapshot** — it copied the child's tools, resources, and prompts at call time, with no live link, and did not run the child's lifespan or middleware. `mount` is a **live composition** — it holds a live link to the child and runs the child's lifespan and middleware. After switching, later changes to the child become visible through the parent, the child's lifespan runs with the parent's (entered when the server starts, held until it stops — not per request), and the child's middleware runs on the operations delegated to it. If you depended on the frozen-copy behavior (a stable snapshot, no child lifecycle), there is no drop-in replacement: register the child's components on the parent directly instead of composing the two servers. -### Removed tool and decorator parameters +### Removed Parameters -Two `@tool` parameters and two settings are gone: +Several parameters and settings that warned in 3.x are gone: - **Tool `serializer=`** is removed from `@tool` / `mcp.tool()`, `Tool.from_function`, `Tool.from_tool`, and the OpenAPI tool. Return a `ToolResult` from your tool for full control over serialization instead. - **Tool `exclude_args=`** is removed. Hide a parameter from the tool schema by injecting it instead: give it a `Depends(factory)` default (from `fastmcp.dependencies`), where `factory` is a callable returning the value the argument used to carry. An injected parameter never appears in the tool's schema, which is what `exclude_args` was for. - **The `decorator_mode` setting** (`FASTMCP_DECORATOR_MODE`) and its `"object"` mode are removed. Decorators always return your original function with metadata attached; reach the component object through the server (`await mcp.get_tool("name")`) rather than off the decorated function. - **`StreamableHttpTransport(sse_read_timeout=...)`** is removed — it was a no-op under the SDK v2 client. Set the read timeout through the public `Client(transport, timeout=...)` (a `timedelta` or float seconds), or reach for a custom `httpx_client_factory` when you need finer control. (`SSETransport` still accepts `sse_read_timeout`.) +- **`ctx.elicit()` now requires `response_type`.** Omitting it (or passing `None`) has warned since 3.2 and now raises `TypeError`. The empty-object schema it produced gave clients nothing to render, and some showed an empty, non-functional form. Pass a type describing what you expect back — `bool` is the right answer for a confirmation: -## Behavior changes to verify + ```python test="skip" + # Before + result = await ctx.elicit("Approve this action?") -Two server-side behaviors changed in ways that compile fine but can surface at runtime. + # After + result = await ctx.elicit("Approve this action?", response_type=bool) + ``` + + This is the server-authoring API only. Client elicitation handlers still receive `response_type=None` for URL requests and for empty schemas sent by other servers — that contract is unchanged. + +### Background Tasks + +Background tasks left the core MCP spec during the SDK v2 rebuild and came back as the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP follows the protocol: what was a built-in server feature in 3.x is now a registered extension, and the authoring surface changed on both sides of the connection. + +The extension ships in a separate package, so the pin from [Install the v4 Prerelease](#install-the-v4-prerelease) needs one more entry before any of this imports: + +```toml +[project] +dependencies = ["fastmcp[tasks]==4.0.0b1"] + +[tool.uv] +constraint-dependencies = [ + "fastmcp-slim==4.0.0b1", + "fastmcp-tasks==4.0.0b1", + "mcp==2.0.0b2", + "mcp-types==2.0.0b2", +] +``` + +On the server, `task=True` still marks a tool as capable of running in the background, but it no longer runs anything by itself — the extension does. Register it, or the server refuses to start: + +```python +from fastmcp import FastMCP +from fastmcp_tasks import TasksExtension + +mcp = FastMCP("MyServer") +mcp.add_extension(TasksExtension()) + +@mcp.tool(task=True) +async def slow_computation(duration: int) -> str: + """A long-running operation.""" + return "done" +``` + +Without the registration, a `task=True` tool raises at startup rather than the first time a client calls the tool: + +``` +RuntimeError: Task-enabled tools (slow_computation) require the tasks extension, +but no extension with identifier 'io.modelcontextprotocol/tasks' is registered. +``` + +`TaskConfig` moved from `fastmcp.server.tasks` to `fastmcp.utilities.tasks`, and the `CurrentDocket` and `CurrentWorker` dependencies moved to `fastmcp_tasks.dependencies`. + +`task=` is now a tool-only keyword. FastMCP 3 accepted it on resource, resource-template, and prompt decorators as well; passing it to `@mcp.resource` or `@mcp.prompt` now raises `TypeError`, and there is no replacement — the extension tasks tool calls only. + +The client API changed shape entirely. In 3.x you opted a single call into background execution with `task=True` and got a handle back. In 4.0 `call_tool` handles a tasked call transparently: if the server runs the call in the background, the client polls it to completion and returns the same result a synchronous call would have produced. + +```python +import fastmcp_tasks # noqa: F401 — importing anywhere enables client task support +from fastmcp import Client + + +async def run(server): + async with Client(server) as client: + return await client.call_tool("slow_computation", {"duration": 10}) +``` + +When you want the handle — to do other work while the task runs, check on it, or cancel it — `call_tool_task` returns one immediately: + +```python +from fastmcp import Client +from fastmcp_tasks import call_tool_task + + +async def run(server): + async with Client(server) as client: + task = await call_tool_task(client, "slow_computation", {"duration": 10}) + return await task.result() +``` + +Three things follow from this. `client.call_tool(name, args, task=True)` raises `TypeError`, as do `read_resource(task=True)` and `get_prompt(task=True)` — and those last two have no replacement. Client task support requires `fastmcp_tasks` to be imported somewhere in the process, since that import is what makes a `Client` advertise the capability. And tasks are negotiated only on modern connections, so a `mode="legacy"` client never gets them. See [Background Tasks](/servers/tasks) for the full picture. + +## Behavior Changes + +These changes compile fine and can surface at runtime. The first is the one most likely to bite a working 3.x server. + +**`ctx.elicit()` no longer reaches a default client.** Elicitation is era-gated in 4.0: `ctx.elicit()` works on handshake-era connections (≤ 2025-11-25) and raises on the modern `2026-07-28` protocol, which has no back-channel for a running tool to push a request down. Because `fastmcp.Client` now defaults to `mode="auto"`, an ordinary client negotiates the modern era against a FastMCP server — so a tool that elicited happily in 3.x now fails the call: + +``` +ToolError: elicitation via server-initiated requests is unavailable on 2026-07-28 connections. +``` + +The gate is strict in both directions, which is what makes it debuggable: a guard tool that returns an input request on a handshake connection raises the mirror-image error rather than misbehaving quietly. You have three ways forward. Rewrite the tool as a guard tool that *returns* a description of the input it needs, which is the form that works on modern connections. Branch on `ctx.request_context.protocol_version` and keep both paths if you serve both eras. Or keep this server's clients on the handshake era with `Client(server, mode="legacy")`, which leaves `ctx.elicit()` working as written. See [Elicitation](/servers/elicitation#which-approach-to-use) for the two shapes side by side. + +**Middleware sees traffic it never saw before.** Dispatch now begins in the SDK's middleware layer, the single point every inbound message passes through, so `on_message`, `on_request`, and `on_notification` observe *every* message a client sends — including `notifications/cancelled`, `notifications/initialized`, and `notifications/progress`, and including requests that fail before reaching a handler, such as an unknown method or a `tools/call` whose params fail validation. In 3.x those never reached your hooks. Middleware that assumed every message it saw was a routable request, or that counted messages to measure tool traffic, needs a guard on the message type. The operation hooks (`on_call_tool`, `on_list_tools`, and the rest) are unaffected: they still fire exactly once per request and still receive typed component results. See [What middleware sees](/servers/middleware#what-middleware-sees). **Templated resources are path-screened by default.** Every templated resource now has its extracted parameter values checked for path-traversal (`..` segments), absolute paths, and null bytes *before your handler runs*, at the server's read chokepoint. A rejected read returns a non-leaky "resource not found" error. Only a standalone `..` segment counts as traversal, so values that merely contain dots (`file.tar.gz`, `HEAD~3..HEAD`) and dotfiles (`.env`) still pass. If a template legitimately accepts `..`-bearing or absolute values, exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable the check per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](/servers/resources#path-security). **Resource-not-found now returns `-32602`.** The wire error code for a missing resource from the core `resources/read` handler changed from `-32002` to `-32602` (`INVALID_PARAMS`, per SEP-2164). The human-readable message ("Resource not found: ...") is unchanged, so this only affects clients that matched on the numeric code — update those to expect `-32602`. (The opt-in `ErrorHandlingMiddleware` keeps its own per-method-prefix code mapping; if you run it with `transform_errors=True` it can still map not-found to a different code, so it is unaffected by this change.) -## Deprecation timeline +**An OAuth server whose `issuer_url` differs from its `base_url` re-authorizes its clients once.** `issuer_url` exists so a server's OAuth identity can differ from the URL its endpoints are mounted at — the usual case being a server under `/api` whose discovery lives at the host root. It now supplies the `issuer` in the authorization server metadata, the `iss` claim on every token the server mints, and the RFC 9207 `iss` on authorization responses; `base_url` still supplies `authorization_endpoint`, `token_endpoint`, and the rest, because that is where the routes are actually mounted. Both values previously came from `base_url`, which published an `issuer` contradicting the URL the client had just performed discovery at — a document RFC 8414 §3.3 requires a strict client to reject. + +The cost of the correction is the `iss` on tokens already in the wild, so it falls on the providers that mint their own tokens — `OAuthProxy` and everything built on it. Access *and* refresh tokens carry the claim, and the verifier compares it exactly, so clients cannot refresh their way across the upgrade; it is a one-time full re-authorization. Interactive clients re-prompt and recover on their own, while a headless deployment holding a long-lived refresh token needs someone to re-authorize it. Plan the upgrade for a window where that is acceptable. If an identity provider mints SEP-990 ID-JAG assertions for this server, repoint their `aud` at the new issuer too — unless you pin the expected value with `IdentityAssertion(audience=...)`, which overrides the issuer and keeps working untouched. + +Servers that leave `issuer_url` unset, or set it to the same value as `base_url`, are unaffected. It defaults to `base_url`, and the metadata and minted `iss` are byte-identical to what 3.x produced. + +## Deprecation Timeline The camelCase bridge is a migration aid, not a permanent fixture. It works today and warns on every bridged read so you can find and update the affected call sites. Plan to migrate your reads to snake_case: the shims will be removed in a future release, after which only the snake_case names resolve — the same state you get today by setting `mcp_camelcase_compat = False`. Turning the setting off is a good way to surface every remaining camelCase read in your code as a hard `AttributeError` before the shims go away. -## SDK deprecation warnings you may see +## SDK Deprecation Warnings -Ordinary use of `ctx.info` (client logging) and `ctx.sample` now emits an SDK-level `MCPDeprecationWarning`: +Ordinary use of `ctx.info` (client logging) emits an SDK-level `MCPDeprecationWarning`: ``` -The logging/sampling capability is deprecated as of 2026-07-28 (SEP-2577) +The logging capability is deprecated as of 2026-07-28 (SEP-2577) ``` -These warnings come from the MCP SDK, not from FastMCP. For logging they are benign: `ctx.info` keeps working on session-based connections exactly as the protocol table below describes, and the SDK is only signaling the protocol's direction. For sampling, FastMCP additionally emits its own `FastMCPDeprecationWarning`: `ctx.sample` and `ctx.sample_step` are deprecated and slated for removal, so treat that warning as a prompt to migrate to server-side LLM calls rather than as informational. +The warning comes from the MCP SDK, not from FastMCP, and it is benign. `ctx.info` and the rest of the logging methods keep working on every era, including the modern one — a log message is a *notification*, which rides the response stream the caller already opened. The SDK is signaling the protocol's direction for the capability declaration, not the notification itself. -## Protocol version support +## Protocol Version Support FastMCP servers built on the SDK v2 serve multiple protocol eras from the same server. The SDK negotiates the era each client speaks: the sessionless `2026-07-28` era (which discovers capabilities through `server/discover`) and earlier session-based handshake versions are all handled simultaneously. This formally supersedes FastMCP's earlier "latest protocol only" stance — a single server now works with clients across the protocol transition. -Not every Context feature is available on every era yet. The imperative push APIs that call back into the client mid-execution — `ctx.elicit`, `ctx.sample`, and `ctx.list_roots` — depend on the session-based back-channel of the earlier eras, so on a `2026-07-28` connection they raise a clear, era-aware error rather than reaching the client. Elicitation itself still reaches the user on the modern era, through the guard pattern: a tool *returns* an `InputRequiredResult` describing what it needs, and the client answers with a fresh call (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Logging notifications and the request/response features flow on every era. +**`ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` are gone from `Context`**, along with the `sampling_handler=` and `sampling_handler_behavior=` arguments to `FastMCP()`. Touching a removed method raises `AttributeError` on every era, and `FastMCP(sampling_handler=...)` raises a `TypeError` naming the migration, so the break surfaces when you upgrade rather than in production against whichever client happens to negotiate the modern era. -Sampling is the exception that does not come back, and the reason is the protocol rather than an unfinished FastMCP feature. SEP-2577 deprecated server-initiated sampling, so `ctx.sample` and `ctx.sample_step` are **deprecated** and will be removed in a future FastMCP release. Elicitation moved to the guard pattern because the modern protocol still carries elicitation requests; sampling has no equivalent path because the protocol deprecated the pattern itself. The migration is to call an LLM directly from your server rather than borrowing the client's model. See [Sampling](/servers/sampling) for details. +All three *pushed*: the server sent a request down a live back-channel and blocked for the answer, and the sessionless protocol has no such channel. Since `fastmcp.Client` now negotiates the modern protocol by default, a method like that would fail against a default client. What the protocol removed is the pushing, not the asking — sampling, elicitation, and roots all still reach the client through the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), where a tool *returns* an `InputRequiredResult` describing what it needs, the client answers, and it calls again with the answer attached. + +Migrating differs by capability. For **roots**, the guard pattern is the direct replacement: a server asks once and has what it needs, so the extra round buys the whole answer, and taking the paths as tool arguments is simpler still when the caller can just supply them. For **sampling**, the guard route works the same way, but generation usually belongs in your server, because every round is a full request-response cycle and a generation loop pays that cost repeatedly. [Call an LLM from your server](/servers/sampling) with your own API key and your tool behaves the same for every client, including the many that never implemented sampling; reach for the guard route when the point is specifically to use the caller's model. If borrowing the caller's model *is* your server — you hold no key of your own, and the token bill was never yours to pay — staying on FastMCP 3.x is the honest answer until that changes. | Context feature | Earlier eras (session-based) | `2026-07-28` (sessionless) | | --- | --- | --- | | `ctx.info` / logging notifications | Supported | Supported | | Tools, resources, prompts, completions | Supported | Supported | -| `ctx.elicit` | Supported | Use the guard pattern (return `InputRequiredResult`) | -| `ctx.sample` / `ctx.sample_step` | Supported (deprecated) | Removed — call an LLM server-side | -| `ctx.list_roots` | Supported | Via the guard pattern (`input_requests` carries roots requests) | +| `ctx.elicit` | Supported | Raises — use the guard pattern (return `InputRequiredResult`) | +| `ctx.sample` / `ctx.sample_step` | Method removed — call an LLM server-side | Method removed — call an LLM server-side, or ask via the guard pattern | +| `ctx.list_roots` | Method removed — take paths as tool arguments | Method removed — ask via the guard pattern, or take paths as tool arguments | +| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` needs session state the era lacks | | `Middleware.on_initialize` | Runs on connect | Never runs — there is no `initialize` handshake | | Session state (`ctx.set_state` across calls) | Persists for the session | Does not persist — every request is a fresh connection | | Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension | -If your tools rely on `ctx.elicit` or `ctx.list_roots`, they continue to work against clients on the earlier eras; on the modern era, reach for the guard pattern instead (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Sampling is deprecated on every era and will not return on modern connections — migrate those tools to server-side LLM calls. +Several of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next; and a tool that calls [`ctx.elicit()`](#behavior-changes) raises. A server that gates access in `on_initialize`, relies on per-session state, or elicits mid-tool must keep its clients on the session-based era. The control is per-client: `Client(server, mode="legacy")`. There is no server-side setting that restricts which protocol versions a server offers, so a server whose behavior depends on the handshake era depends on its callers opting into it — which is only practical when you control them. If you don't, port the behavior instead: a guard tool for elicitation, [session state](/servers/sessions) for what `ctx.set_state` held, and per-request auth checks for what `on_initialize` gated. -Two of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; and each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next. A server that gates access in `on_initialize` or relies on per-session state must keep its clients on the session-based era. The narrow escape is per-client: `Client(server, mode="legacy")`. The durable, server-side answer is to declare the versions the server actually serves so a modern client is refused at connect time rather than silently losing those features — see the server's protocol-version restriction (added alongside this change). +The client side is unaffected. `sampling_handler=` and `roots=` mean what they always did — see [client sampling](/clients/sampling) and [client roots](/clients/roots) — and one registration serves both routes, since a handshake-era server's pushed request and a modern server's returned one dispatch to the same handler. -## Upgrade checklist +## Upgrade Checklist Most servers upgrade untouched. Work down this list to find the ones that don't: 1. **Bump your environment.** Raise any pin below `pydantic>=2.12`; upgrade FastAPI if your resolver complains about Starlette `<1.0.1`. -2. **Fix imports that moved out.** Replace `from mcp.types import X` with `from mcp_types import X`, and update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims). -3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods-and-mount-keywords). -4. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`. -5. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged. -6. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`. -7. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize` or per-session state, keep its clients on `mode="legacy"` or restrict the server's served protocol versions. -8. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, and update any client that matched the old `-32002` resource-not-found code. -9. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed. +2. **Fix imports that moved out.** `from mcp.types import X` still works, but update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims). +3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods). +4. **Replace `ctx.sample` and `ctx.list_roots`.** Both are gone from `Context`, as are `FastMCP(sampling_handler=...)` and `sampling_handler_behavior=`. Call an LLM directly from your server for generation; ask for roots through the guard pattern, or take file paths as tool arguments. A server whose purpose is to use the caller's model should stay on FastMCP 3.x rather than migrate. +5. **Find every `ctx.elicit()` call.** It raises on modern connections, which is what a default client now negotiates. Rewrite the tool as a guard tool, branch on `ctx.request_context.protocol_version`, or keep its clients on `mode="legacy"` — see [the era gate](#behavior-changes). +6. **Register the tasks extension.** A `task=True` tool needs `mcp.add_extension(TasksExtension())` or the server won't start. Drop `task=` from resource and prompt decorators, move `TaskConfig` to `fastmcp.utilities.tasks`, and replace client-side `call_tool(..., task=True)` with plain `call_tool` or `call_tool_task`. +7. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`. +8. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged. +9. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`. +10. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize`, per-session state, or `ctx.elicit()`, keep its clients on `mode="legacy"`, or port the behavior forward — there is no server-side protocol-version restriction. +11. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, guard any middleware that now sees notifications and unroutable requests, update any client that matched the old `-32002` resource-not-found code, and if your server mints its own OAuth tokens (`OAuthProxy` and the providers built on it) under an `issuer_url` that differs from its `base_url`, schedule the [one-time re-authorization](#behavior-changes) its clients now need. +12. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed. The executable version of this checklist lives in [`tests/test_upgrade_from_v3.py`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/test_upgrade_from_v3.py): it builds representative 3.x-style servers and asserts they run unchanged, and pins every removed surface to the exact error it now raises. diff --git a/docs/getting-started/upgrading/from-low-level-sdk.mdx b/docs/getting-started/upgrading/from-low-level-sdk-v1.mdx similarity index 61% rename from docs/getting-started/upgrading/from-low-level-sdk.mdx rename to docs/getting-started/upgrading/from-low-level-sdk-v1.mdx index ab49f1574..35f5412bb 100644 --- a/docs/getting-started/upgrading/from-low-level-sdk.mdx +++ b/docs/getting-started/upgrading/from-low-level-sdk-v1.mdx @@ -1,7 +1,7 @@ --- -title: Upgrading from the MCP Low-Level SDK -sidebarTitle: "From MCP Low-Level SDK" -description: Upgrade your MCP server from the low-level Python SDK's Server class to FastMCP +title: Upgrading from the Low-Level SDK v1 +sidebarTitle: "From Low-Level SDK v1" +description: Upgrade your MCP server from v1 of the low-level Python SDK's Server class to FastMCP icon: up --- @@ -9,78 +9,89 @@ If you've been building MCP servers directly on the `mcp` package's `Server` cla The core idea: instead of telling the SDK what your tools look like and then separately implementing them, you write ordinary Python functions and let FastMCP derive the protocol layer from your code. Type hints become JSON Schema. Docstrings become descriptions. Return values are serialized automatically. The plumbing you wrote to satisfy the protocol just disappears. -## Why now is the moment to switch +## The SDK v2 Transition -MCP SDK v2 landed sweeping breaking changes on the low-level `Server`: the protocol types moved out of `mcp.types` into a separate `mcp_types` package, every field was renamed from camelCase to snake_case, the `Server` class was rebuilt, `McpError` was renamed, and sessions were removed on the new sessionless protocol era. If you build directly on the low-level SDK, all of that lands on you — you have to rewrite your imports, your handler signatures, and your error construction to match the new surface. +MCP SDK v2 is a substantial, deliberate modernization of the protocol layer. Protocol types moved into a standalone `mcp_types` package, wire fields moved from camelCase to snake_case, and the low-level `Server` was rebuilt so handlers are passed to the constructor as `on_*` callables taking `(ctx, params)` rather than registered with decorators. A v1 server meets that change the moment its environment resolves `mcp` to v2: -Adopting FastMCP is the easier path. FastMCP 4 runs on SDK v2 and hides that entire surface behind a high-level API that did not change. You write `@mcp.tool` and never touch the renamed internals — FastMCP derives the protocol layer from your function signatures, so the SDK v2 rename simply isn't something your code has to know about. Migrating low-level-SDK-v1 code to FastMCP is less work than migrating it to raw SDK v2, and you come out the other side with the whole framework: composition, middleware, proxies, authentication, and testing. The SDK v2 break is the natural moment to make the jump. +``` +AttributeError: 'Server' object has no attribute 'list_tools' +``` + +Often nobody chose that moment. An unpinned `mcp` dependency, a fresh lockfile, or a rebuilt container picks up the new major version. Nothing is wrong with your code, and nothing is wrong with the SDK — major versions are exactly where a change like this belongs. Your build just crossed it earlier than you planned to. + +Pinning the SDK back restores the decorator API immediately, with no code changes, and buys you time to choose deliberately: + +```bash +pip install "mcp<2" +``` + +## Two Upgrade Paths + +Both directions are reasonable, and the choice is about which code you'd rather maintain. + +**Porting the low-level `Server` to SDK v2** keeps you in direct control of the protocol surface, which is the point of the low-level API and the right call for some servers. The work is real: your imports, every handler signature, every handler's return type, and your error construction all move. + +**Adopting FastMCP** is what the rest of this page walks through. What makes it less work is not that FastMCP is better — it's that the code most affected by the SDK v2 changes is precisely the code FastMCP doesn't ask you to write. Your `list_tools`/`call_tool` pair, hand-written JSON Schema, and content-block wrappers aren't ported to new signatures; they're deleted, and FastMCP derives all of it from your function signatures instead. FastMCP 4 runs on MCP SDK v2 underneath, so both paths land you on the same modern protocol layer. -Already using FastMCP 1.0 via `from mcp.server.fastmcp import FastMCP`? Your upgrade is simpler — see the [FastMCP 1.0 upgrade guide](/getting-started/upgrading/from-mcp-sdk) instead. +Already on SDK v2's rebuilt `Server` class, with constructor-registered `on_*` handlers? See [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2) instead — the before-and-after code is different enough to warrant its own guide. + +Using FastMCP 1.0 via `from mcp.server.fastmcp import FastMCP`? Your upgrade is a single import — see [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1). -You are upgrading an MCP server from the `mcp` package's low-level Server class (v1) to FastMCP 4. The server currently uses `mcp.server.Server` (or `mcp.server.lowlevel.server.Server`) with manual handler registration. Analyze the provided code and rewrite it using FastMCP's high-level API. The full guide is at https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context. +You are rewriting an MCP server built on v1 of the `mcp` package's low-level `Server` class (`mcp.server.Server` or `mcp.server.lowlevel.server.Server`, with decorator-registered handlers) using FastMCP 4's high-level API. -UPGRADE RULES: +FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1 — it explains every item below, with before-and-after code for each handler group. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs. -1. IMPORTS: Replace all `mcp.*` imports with FastMCP equivalents. - - `from mcp.server import Server` or `from mcp.server.lowlevel.server import Server` → `from fastmcp import FastMCP` - - `import mcp.types as types` → remove (not needed for most code) - - `from mcp.server.stdio import stdio_server` → remove (handled by mcp.run()) - - `from mcp.server.sse import SseServerTransport` → remove (handled by mcp.run()) +Then work through the provided code. This is a rewrite, not a patch: most of what you find gets deleted rather than translated. -2. SERVER: Replace `Server("name")` with `FastMCP("name")`. +CONSTRUCTION AND TRANSPORT +- `Server("name")` +- `async with stdio_server() as (r, w): await server.run(r, w, server.create_initialization_options())` +- `SseServerTransport` / `StreamableHTTPSessionManager` and any Starlette wiring around them +- `asyncio.run(main())` boilerplate +- `lifespan=` — carries over directly: pass the same async context manager to `FastMCP(lifespan=...)`, and read what it yields from `ctx.lifespan_context` in any tool. Do not drop it — the tools that depended on it (a DB connection, a client pool) lose their dependency silently if you do. -3. TOOLS: Replace the list_tools + call_tool handler pair with individual @mcp.tool decorators. - - Delete the `@server.list_tools()` handler entirely - - Delete the `@server.call_tool()` handler entirely - - For each tool that was listed in list_tools and dispatched in call_tool, create a new function: - - Decorate it with `@mcp.tool` - - Use the tool name as the function name (or pass name= to the decorator) - - Use the docstring for the description (or pass description= to the decorator) - - Convert the inputSchema JSON Schema into typed Python parameters (e.g., `{"type": "integer"}` → `int`, `{"type": "string"}` → `str`, `{"type": "array", "items": {"type": "string"}}` → `list[str]`) - - Return plain Python values (`str`, `int`, `dict`, etc.) instead of `list[types.TextContent(...)]` - - If the tool returned `types.ImageContent` or `types.EmbeddedResource`, use `from fastmcp.utilities.types import Image` or return the appropriate type +HANDLERS TO DELETE (each becomes one or more decorated functions) +- `@server.list_tools()` + `@server.call_tool()` — note the `if name == ...` dispatch chain inside call_tool; each branch becomes its own `@mcp.tool` +- `@server.list_resources()` + `@server.list_resource_templates()` + `@server.read_resource()` — note any manual URI parsing, which the `{placeholder}` syntax replaces +- `@server.list_prompts()` + `@server.get_prompt()` +- any other `@server.*()` handler in the file — completion, resource subscribe/unsubscribe, logging level, progress. Look these up in the FastMCP docs rather than assuming a decorator name maps one-to-one. -4. RESOURCES: Replace the list_resources + list_resource_templates + read_resource handler trio with individual @mcp.resource decorators. - - Delete all three handlers - - For each static resource, create a function decorated with `@mcp.resource("uri://...")` - - For each resource template, use `@mcp.resource("uri://{param}/path")` with `{param}` in the URI and a matching function parameter - - Return str for text content, bytes for binary content - - Set `mime_type=` in the decorator if needed +TYPES THAT DISAPPEAR FROM YOUR CODE +- hand-written `inputSchema` JSON Schema dicts — these come from type hints now +- `types.Tool`, `types.Resource`, `types.ResourceTemplate`, `types.Prompt`, `types.PromptArgument` +- `types.TextContent` wrappers around return values — return plain Python values instead +- `types.ImageContent`, `types.EmbeddedResource` +- `types.PromptMessage`, `types.GetPromptResult` +- Note that in the SDK v2 that FastMCP 4 builds on, `mcp.types` aliases the standalone `mcp_types` package; the import path still works, but the fields are snake_case now. -5. PROMPTS: Replace the list_prompts + get_prompt handler pair with individual @mcp.prompt decorators. - - Delete both handlers - - For each prompt, create a function decorated with `@mcp.prompt` - - Convert PromptArgument definitions into typed function parameters - - Return str for simple single-message prompts (auto-wrapped as user message) - - Return `list[Message]` for multi-message prompts: `from fastmcp.prompts import Message` - - `Message("text")` defaults to `role="user"`; use `Message("text", role="assistant")` for assistant messages +CONTEXT AND SIDE CHANNELS +- `server.request_context` +- `session.send_log_message(...)`, `session.send_progress_notification(...)` +- direct session use for anything else — a FastMCP `Context` has a `ctx.session` property returning the underlying SDK session, so this still works; prefer a `Context` method where one exists, and note the remaining uses as SDK-coupled -6. TRANSPORT: Replace all transport boilerplate with mcp.run(). - - `async with stdio_server() as (r, w): await server.run(r, w, ...)` → `mcp.run()` (`stdio` is the default) - - SSE/Starlette setup → `mcp.run(transport="sse", host="...", port=...)` - - Streamable HTTP setup → `mcp.run(transport="http", host="...", port=...)` - - Delete asyncio.run(main()) boilerplate — use `if __name__ == "__main__": mcp.run()` +ERRORS +- `raise ValueError(f"Unknown tool: ...")` and other dispatch fallbacks — these become unnecessary +- `McpError` construction and any error-code mapping -7. CONTEXT: Replace `server.request_context` with FastMCP's Context parameter. - - Add `from fastmcp import Context` and add a `ctx: Context` parameter to any tool that needs it - - `server.request_context.session.send_log_message(...)` → `await ctx.info("message")` or `await ctx.warning("message")` - - Progress reporting → `await ctx.report_progress(current, total)` - -For each change, show the original code, explain what it did, and provide the FastMCP equivalent. +For each item found, show the original code, say what it did, and give the FastMCP equivalent. Where several handlers collapse into one decorated function, show the collapse rather than a line-by-line mapping. Call out anything you could not find a documented FastMCP replacement for instead of inventing one. ## Install +FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3: + ```bash -pip install --upgrade fastmcp +pip install "fastmcp==4.0.0b1" # or -uv add fastmcp +uv add "fastmcp==4.0.0b1" ``` -FastMCP includes the `mcp` package as a transitive dependency, so you don't lose access to anything. +An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease). + +FastMCP depends on the `mcp` package, so the SDK stays installed. FastMCP 4 builds on SDK v2, where the protocol types live in a standalone `mcp_types` package that stays importable as `mcp.types`. Most of your `mcp.types` imports disappear entirely in the rewrite below, since FastMCP derives the protocol types from your function signatures. ## Server and Transport @@ -88,7 +99,7 @@ The `Server` class requires you to choose a transport, connect streams, build in -```python Before +```python Before test="skip" import asyncio from mcp.server import Server from mcp.server.stdio import stdio_server @@ -124,7 +135,12 @@ if __name__ == "__main__": Need HTTP instead of stdio? With the `Server` class, you'd wire up Starlette routes and `SseServerTransport` or `StreamableHTTPSessionManager`. With FastMCP: ```python -mcp.run(transport="http", host="0.0.0.0", port=8000) +from fastmcp import FastMCP + +mcp = FastMCP("my-server") + +if __name__ == "__main__": + mcp.run(transport="http", host="0.0.0.0", port=8000) ``` ## Tools @@ -133,7 +149,7 @@ This is where the difference is most dramatic. The `Server` class requires two h -```python Before +```python Before test="skip" import mcp.types as types from mcp.server import Server @@ -240,7 +256,7 @@ The `Server` class uses three handlers for resources: `list_resources()` to enum -```python Before +```python Before test="skip" import json import mcp.types as types from mcp.server import Server @@ -333,7 +349,7 @@ Same pattern: the `Server` class uses `list_prompts()` and `get_prompt()` with m -```python Before +```python Before test="skip" import mcp.types as types from mcp.server import Server @@ -420,7 +436,7 @@ The `Server` class exposes request context through `server.request_context`, whi -```python Before +```python Before test="skip" import mcp.types as types from mcp.server import Server @@ -458,13 +474,31 @@ async def process_data(ctx: Context) -> str: The `Context` object provides logging (`ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`), progress reporting (`ctx.report_progress()`), resource subscriptions, session state, and more. See [Context](/servers/context) for the full API. +## Errors + +Most of the errors a low-level server raises disappear with the dispatch that raised them: the `ValueError(f"Unknown tool: {name}")` fallback is unnecessary once FastMCP routes calls, and an exception from your function body is converted to a tool error for you. + +Deliberate protocol errors are the exception, and they need a small rewrite. The v1 pattern wrapped an `ErrorData` and passed it positionally; FastMCP's `McpError` takes the fields directly: + +```python test="skip" +from fastmcp.exceptions import McpError + +# Before (SDK v1): +# raise McpError(ErrorData(code=-32000, message="Upstream unavailable")) + +# After: +raise McpError(code=-32000, message="Upstream unavailable") +``` + +An optional third argument, `data=`, carries the structured payload `ErrorData` used to hold. Catching is unchanged — `except McpError` still works, and `err.error.code` still reads the code — so only construction sites need touching. + ## Complete Example A full server upgrade, showing how all the pieces fit together: -```python Before expandable +```python Before expandable test="skip" import asyncio import json import mcp.types as types @@ -580,15 +614,10 @@ if __name__ == "__main__": -## What's Next +## What You Gain -Once you've upgraded, you have access to everything FastMCP provides beyond the basics: +Deleting the handler machinery is the immediate payoff, but the reason to make this move is what becomes available once your server is a FastMCP server. -- **[Server composition](/servers/composition)** — Mount sub-servers to build modular applications -- **[Middleware](/servers/middleware)** — Add logging, rate limiting, error handling, and caching -- **[Proxy servers](/servers/providers/proxy)** — Create a proxy to any existing MCP server -- **[OpenAPI integration](/integrations/openapi)** — Generate an MCP server from an OpenAPI spec -- **[Authentication](/servers/auth/authentication)** — Built-in OAuth and token verification -- **[Testing](/servers/testing)** — Test your server directly in Python without running a subprocess +[Server composition](/servers/composition) mounts one server inside another, so a surface that grew unwieldy as a single `call_tool` dispatch splits into modules developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching — the cross-cutting concerns that, on the low-level `Server`, meant threading the same code through every handler. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control, and the [OpenAPI integration](/integrations/openapi) generates an entire server from an API specification you already have. [Authentication](/servers/auth/authentication) arrives as a single `auth=` provider covering token verification, OAuth, and named providers for GitHub, Google, Auth0, and others. -Explore the full documentation at [gofastmcp.com](https://gofastmcp.com). +The change most likely to affect your daily work is [testing](/servers/testing). FastMCP ships a client that connects to a server object in the same Python process, so a test calls your tools directly — no subprocess, no stdio pipes, no transport to stand up. diff --git a/docs/getting-started/upgrading/from-low-level-sdk-v2.mdx b/docs/getting-started/upgrading/from-low-level-sdk-v2.mdx new file mode 100644 index 000000000..f4222c0f0 --- /dev/null +++ b/docs/getting-started/upgrading/from-low-level-sdk-v2.mdx @@ -0,0 +1,622 @@ +--- +title: Upgrading from the Low-Level SDK v2 +sidebarTitle: "From Low-Level SDK v2" +description: Move a server built on v2 of the low-level Python SDK's Server class to FastMCP +icon: up +--- + +If your server builds on the `mcp` package's low-level `Server` class as SDK v2 rebuilt it — handlers passed to the constructor as `on_list_tools`, `on_call_tool`, and their siblings, each taking `(ctx, params)` and returning a wrapped result object — this guide is for you. FastMCP replaces that machinery with a declarative API where your functions *are* the protocol surface. + +The core idea: instead of describing your tools to the SDK and then separately implementing them, you write ordinary Python functions and let FastMCP derive the protocol layer from your code. Type hints become JSON Schema. Docstrings become descriptions. Return values are serialized automatically. The dispatch you wrote to route a call by name, and the schemas you wrote by hand to describe it, both disappear. + +Migrating from SDK v2 is the most direct of the four upgrade paths, because you and FastMCP already share a protocol layer. FastMCP 4 is built on SDK v2, so `mcp_types` imports keep working, field names are already snake_case, and the era negotiation you get is the one you have. Almost nothing about the wire changes — the one exception is [argument strictness](#stricter-arguments), covered below. + + +On SDK v1's decorator-registered `Server` — `@server.list_tools()`, `@server.call_tool()` — instead? See [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1), where the before-and-after code matches that API. + +Using SDK v2's high-level `MCPServer` class? See [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2) — that migration is mostly renaming. + + + +You are rewriting an MCP server built on the MCP Python SDK v2's low-level `Server` class (`mcp.server.lowlevel.server.Server`, with `on_*` handlers passed to the constructor) using FastMCP 4's high-level API. + +FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2 — it explains every item below in full, with before-and-after code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not guess at a FastMCP API you have not confirmed in the docs. + +Then work through the provided code looking for each of these. The guide has the replacement for every one: + +CONSTRUCTION AND TRANSPORT +- `Server(name, on_list_tools=..., on_call_tool=..., ...)` — the whole constructor, including every handler passed to it +- `server.run(read_stream, write_stream, server.create_initialization_options())` and its `stdio_server()` context manager +- `server.streamable_http_app()` and any Starlette app assembled around it +- `asyncio.run(main())` boilerplate +- `lifespan=` — carries over directly: pass the same async context manager to `FastMCP(lifespan=...)`, and read what it yields from `ctx.lifespan_context` in any tool. Do not drop it — the tools that depended on it (a DB connection, a client pool) lose their dependency silently if you do. + +HANDLERS TO DELETE, EACH REPLACED BY ONE DECORATOR (not simply removed) +- `on_list_tools` + `on_call_tool` → one `@mcp.tool` function per branch of the `if params.name == ...` dispatch chain inside `on_call_tool` +- `on_list_resources` + `on_list_resource_templates` + `on_read_resource` → one `@mcp.resource` function per resource/template +- `on_list_prompts` + `on_get_prompt` → one `@mcp.prompt` function per prompt +- `on_completion` → one `@mcp.completion` function. This one is easy to drop by mistake: skipping it does not just remove autocomplete cleanly, it silently stops FastMCP from advertising the completions capability at all, since that capability is only advertised when a handler is registered. +- `on_subscribe_resource` / `on_unsubscribe_resource` / `on_subscriptions_listen` — flag for the user, no single-decorator equivalent +- `on_set_logging_level`, `on_progress`, `on_roots_list_changed`, `on_ping` — flag for the user, these are protocol-level hooks with no direct FastMCP surface + +TYPES THAT DISAPPEAR FROM YOUR CODE +- Hand-written `input_schema` / `output_schema` JSON Schema dicts — these come from type hints now +- `types.ListToolsResult`, `types.CallToolResult`, `types.ListResourcesResult`, `types.ListResourceTemplatesResult`, `types.ReadResourceResult`, `types.ListPromptsResult`, `types.GetPromptResult` — result wrappers FastMCP builds for you +- `types.TextContent`, `types.TextResourceContents`, `types.BlobResourceContents` — return plain Python values instead +- `types.ImageContent` / `types.AudioContent` — `fastmcp.utilities.types.Image` / `Audio` +- `types.Tool`, `types.Resource`, `types.ResourceTemplate`, `types.Prompt`, `types.PromptArgument` — declaration types FastMCP derives +- `types.PromptMessage` — `fastmcp.prompts.Message` +- Note which `mcp_types` imports are still needed afterward; protocol types are unchanged in FastMCP, so surviving imports stay as they are. + +CONTEXT AND SIDE CHANNELS +- `ctx.session.send_log_message(...)` — `ctx.info()` / `ctx.debug()` / `ctx.warning()` / `ctx.error()` on a `fastmcp.Context` parameter +- `ctx.session.report_progress(...)` — `ctx.report_progress()` +- `ctx.request_id`, `ctx.meta`, `ctx.protocol_version` — these live on `ctx.request_context` in FastMCP (`ctx.request_context.request_id`, and so on); note that `ctx.protocol_version` directly on the Context does not exist +- `ctx.params` — no equivalent, and none is needed: the raw request params were how a low-level handler read the tool's arguments, and those are now the decorated function's typed parameters. `ctx.request_context.params` does NOT exist and raises AttributeError. +- Direct `ctx.session` use for anything else — `Context.session` exists in FastMCP too and returns the same raw SDK session, so this still works; prefer a `Context` method where one exists, and note the remaining uses as SDK-coupled + +ERRORS AND AUTH +- `raise ValueError(f"Unknown tool: ...")` dispatch fallbacks — these become unnecessary +- `MCPError` construction and any error-code mapping +- `auth=AuthSettings(...)`, `token_verifier=`, `auth_server_provider=` — one `auth=` provider in FastMCP +- `TransportSecuritySettings` + +For each item found, show the original code, say what it did, and give the FastMCP equivalent. Where several handlers collapse into one decorated function, show the collapse rather than a line-by-line mapping. Call out anything you could not find a documented FastMCP replacement for instead of inventing one. + + +## Install + +FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3: + +```bash +pip install "fastmcp==4.0.0b1" +# or +uv add "fastmcp==4.0.0b1" +``` + +An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease). + +FastMCP 4 depends on the MCP SDK v2 you are already using, so `mcp_types` stays importable and every protocol type keeps its current name and fields. Most of those imports vanish from your code anyway — FastMCP derives them — but the ones you keep need no changes. + +## Server and Transport + +The `Server` class asks you to open a transport, connect its streams, build initialization options, and run an event loop. FastMCP collapses that into a constructor and a `run()` call. + + + +```python Before test="skip" +import asyncio + +from mcp.server.lowlevel.server import Server +from mcp.server.stdio import stdio_server + +server = Server("my-server") # plus every on_* handler + +async def main(): + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + +asyncio.run(main()) +``` + +```python After +from fastmcp import FastMCP + +mcp = FastMCP("my-server") + +# ... register tools, resources, prompts ... + +if __name__ == "__main__": + mcp.run() +``` + + + +Serving HTTP is the same shape. Where the low-level class hands you a Starlette app from `server.streamable_http_app()` and leaves the hosting to you, FastMCP runs it directly: + +```python +from fastmcp import FastMCP + +mcp = FastMCP("my-server") + +if __name__ == "__main__": + mcp.run(transport="http", host="0.0.0.0", port=8000) +``` + +`mcp.http_app()` still returns a Starlette app when you need to mount the server inside a larger application. + +## Tools + +This is where the difference is largest. SDK v2 requires two handlers — one describing your tools with hand-written JSON Schema, one dispatching calls by name — and both are passed to the constructor, so the connection between a tool's declaration and its implementation lives only in your head. FastMCP derives both from the function. + + + +```python Before +import mcp_types as types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel.server import Server + + +async def list_tools(ctx: ServerRequestContext, params) -> types.ListToolsResult: + number = {"type": "number"} + schema = { + "type": "object", + "properties": {"a": number, "b": number}, + "required": ["a", "b"], + } + return types.ListToolsResult( + tools=[ + types.Tool(name="add", description="Add two numbers", input_schema=schema), + types.Tool( + name="multiply", description="Multiply two numbers", input_schema=schema + ), + ] + ) + + +async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams +) -> types.CallToolResult: + arguments = params.arguments or {} + if params.name == "add": + result = arguments["a"] + arguments["b"] + elif params.name == "multiply": + result = arguments["a"] * arguments["b"] + else: + raise ValueError(f"Unknown tool: {params.name}") + return types.CallToolResult(content=[types.TextContent(type="text", text=str(result))]) + + +server = Server("math", on_list_tools=list_tools, on_call_tool=call_tool) +``` + +```python After +from fastmcp import FastMCP + +mcp = FastMCP("math") + + +@mcp.tool +def add(a: float, b: float) -> float: + """Add two numbers""" + return a + b + + +@mcp.tool +def multiply(a: float, b: float) -> float: + """Multiply two numbers""" + return a * b +``` + + + +Each `@mcp.tool` function is self-contained: its name becomes the tool name, its docstring becomes the description, its annotations become the JSON Schema, and its return value is serialized for you. The dispatch chain, the schema dicts, the `CallToolResult` wrapper, the `TextContent` wrapper, and the unknown-tool fallback all go away — a tool that doesn't exist is now the framework's problem, not a branch you maintain. + +### Type Mapping + +Your hand-written `input_schema` becomes the function's parameters: + +| JSON Schema | Python type | +|---|---| +| `{"type": "string"}` | `str` | +| `{"type": "number"}` | `float` | +| `{"type": "integer"}` | `int` | +| `{"type": "boolean"}` | `bool` | +| `{"type": "array", "items": {"type": "string"}}` | `list[str]` | +| `{"type": "object"}` | `dict` | +| A property absent from `required` | `param: str \| None = None` | + +Constraints carry over too. A schema with `"minimum"` and `"maximum"` becomes a Pydantic `Field`, and a nested object schema becomes a Pydantic model or dataclass used as the annotation — FastMCP generates the same schema back out of it. + +### Return Values + +The low-level class requires tools to return a `CallToolResult` wrapping a list of content blocks. FastMCP takes the value itself — strings, numbers, dicts, lists, dataclasses, Pydantic models — and handles both the content block and the structured output. For images and audio, FastMCP provides wrapper types that carry the format: + +```python +from fastmcp import FastMCP +from fastmcp.utilities.types import Image + +mcp = FastMCP("media") + + +@mcp.tool +def create_chart(data: list[float]) -> Image: + """Generate a chart from data.""" + png_bytes = render_png(data) # your logic + return Image(data=png_bytes, format="png") +``` + +When you need full control over the wire result — multiple content blocks, or structured content that differs from the content blocks — return a `ToolResult` from `fastmcp.tools` instead. + +### Stricter Arguments + +Deriving the schema from your signature also tightens what callers may send, and this is the one behavior change the migration introduces. Your `on_call_tool` handler reads `params.arguments` as a plain dict and never looks at keys it doesn't need, so a call carrying an unexpected key succeeds. FastMCP declares `"additionalProperties": false` on the generated schema and enforces it, so the same call fails: + +```python test="skip" +# Against the low-level handler: succeeds, "extra" never read. +# Against FastMCP: raises, "extra" is not a parameter of greet(). +await client.call_tool("greet", {"name": "World", "extra": "surprise"}) +``` + +For most servers this is an improvement that costs nothing — a caller sending keys your handler never read was already a bug, and the hand-written schema never advertised that they were allowed. It matters if a client in your fleet attaches metadata alongside real arguments, since those calls start failing the moment you migrate. Accept them explicitly as optional parameters if you need to keep them working. + +## Resources + +Resources take three handlers on the low-level class: one to list static resources, one to list URI templates, and one to read whichever URI arrives, with routing you write by hand. FastMCP replaces all three with a decorator per resource, and detects templates from the URI itself. + + + +```python Before +import json + +import mcp_types as types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel.server import Server + + +async def list_resources(ctx: ServerRequestContext, params) -> types.ListResourcesResult: + return types.ListResourcesResult( + resources=[ + types.Resource( + uri="config://app", + name="app_config", + description="Application configuration", + mime_type="application/json", + ) + ] + ) + + +async def list_resource_templates( + ctx: ServerRequestContext, params +) -> types.ListResourceTemplatesResult: + return types.ListResourceTemplatesResult( + resource_templates=[ + types.ResourceTemplate( + uri_template="users://{user_id}/profile", + name="user_profile", + description="User profile by ID", + ) + ] + ) + + +async def read_resource( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams +) -> types.ReadResourceResult: + uri = str(params.uri) + if uri == "config://app": + text = json.dumps({"debug": False, "version": "1.0"}) + elif uri.startswith("users://"): + user_id = uri.split("/")[2] + text = json.dumps({"id": user_id, "name": f"User {user_id}"}) + else: + raise ValueError(f"Unknown resource: {uri}") + return types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=params.uri, mime_type="application/json", text=text + ) + ] + ) + + +server = Server( + "data", + on_list_resources=list_resources, + on_list_resource_templates=list_resource_templates, + on_read_resource=read_resource, +) +``` + +```python After +import json + +from fastmcp import FastMCP + +mcp = FastMCP("data") + + +@mcp.resource("config://app", mime_type="application/json") +def app_config() -> str: + """Application configuration""" + return json.dumps({"debug": False, "version": "1.0"}) + + +@mcp.resource("users://{user_id}/profile", mime_type="application/json") +def user_profile(user_id: str) -> str: + """User profile by ID""" + return json.dumps({"id": user_id, "name": f"User {user_id}"}) +``` + + + +The URI does the routing. A `{placeholder}` in the URI makes the resource a template, and FastMCP matches the parameter to the function argument of the same name — so the `uri.split("/")[2]` parsing goes away along with the handler that held it. Return a `str` for text content and `bytes` for binary; FastMCP builds the `TextResourceContents` or `BlobResourceContents` wrapper. + +Templated resources also gain a protection the low-level version left to you: FastMCP screens extracted parameter values for path traversal, absolute paths, and null bytes before your function runs. See [Path Security](/servers/resources#path-security) if a template legitimately accepts those values. + +## Prompts + +The same collapse, one more time: `on_list_prompts` declares arguments as `PromptArgument` objects, `on_get_prompt` routes by name and assembles a `GetPromptResult` of `PromptMessage` objects. FastMCP takes a function whose parameters are the arguments. + + + +```python Before +import mcp_types as types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel.server import Server + + +async def list_prompts(ctx: ServerRequestContext, params) -> types.ListPromptsResult: + return types.ListPromptsResult( + prompts=[ + types.Prompt( + name="review_code", + description="Review code for issues", + arguments=[ + types.PromptArgument( + name="code", description="The code to review", required=True + ), + types.PromptArgument( + name="language", description="Programming language", required=False + ), + ], + ) + ] + ) + + +async def get_prompt( + ctx: ServerRequestContext, params: types.GetPromptRequestParams +) -> types.GetPromptResult: + if params.name != "review_code": + raise ValueError(f"Unknown prompt: {params.name}") + arguments = params.arguments or {} + language = arguments.get("language", "") + note = f" (written in {language})" if language else "" + text = f"Please review this code{note}:\n\n{arguments.get('code', '')}" + return types.GetPromptResult( + description="Code review prompt", + messages=[ + types.PromptMessage( + role="user", content=types.TextContent(type="text", text=text) + ) + ], + ) + + +server = Server("prompts", on_list_prompts=list_prompts, on_get_prompt=get_prompt) +``` + +```python After +from fastmcp import FastMCP + +mcp = FastMCP("prompts") + + +@mcp.prompt +def review_code(code: str, language: str | None = None) -> str: + """Review code for issues""" + note = f" (written in {language})" if language else "" + return f"Please review this code{note}:\n\n{code}" +``` + + + +Returning a `str` wraps it as a single user message. Whether an argument is required is read from the signature: `code` has no default, so it's required; `language` defaults to `None`, so it isn't. Multi-turn prompts return a list of `Message` objects, which take their text positionally and default to the user role: + +```python +from fastmcp import FastMCP +from fastmcp.prompts import Message + +mcp = FastMCP("prompts") + + +@mcp.prompt +def debug_session(error: str) -> list[Message]: + """Start a debugging conversation""" + return [ + Message(f"I'm seeing this error:\n\n{error}"), + Message("I'll help you debug that. Can you share the relevant code?", role="assistant"), + ] +``` + +## Request Context + +The low-level class hands each handler a `ServerRequestContext` carrying the raw `ServerSession`, and you reach through it to send notifications. FastMCP injects a typed `Context` into any function that declares one, and puts the operations you actually want on it directly. + + + +```python Before +import mcp_types as types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel.server import Server + + +async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams +) -> types.CallToolResult: + if params.name == "process_data": + await ctx.session.send_log_message(level="info", data="Starting processing...") + await ctx.session.report_progress(1, 2) + # ... do work ... + await ctx.session.send_log_message(level="info", data="Done!") + return types.CallToolResult( + content=[types.TextContent(type="text", text="Processed")] + ) + raise ValueError(f"Unknown tool: {params.name}") + + +server = Server("worker", on_call_tool=call_tool) +``` + +```python After +from fastmcp import FastMCP, Context + +mcp = FastMCP("worker") + + +@mcp.tool +async def process_data(ctx: Context) -> str: + """Process data with progress logging""" + await ctx.info("Starting processing...") + await ctx.report_progress(1, 2) + # ... do work ... + await ctx.info("Done!") + return "Processed" +``` + + + +The `Context` parameter is injected by type annotation and never appears in the tool's schema, so clients see `process_data` as taking no arguments. Beyond logging and progress, it carries resource reads, [session state](/servers/sessions), elicitation, and component visibility — see [Context](/servers/context) for the full surface. + +One thing to check as you migrate: `ctx.session` still exists on a FastMCP `Context` as an escape hatch, and it hands back the same raw SDK session your handlers use today. That makes it a working translation for anything with no `Context` equivalent — but it's also the one part of your server that stays coupled to SDK internals, so reach for the `Context` method first and keep the escape hatch for what genuinely has no equivalent. + +## Complete Example + +Everything above, applied at once: + + + +```python Before expandable +import json + +import mcp_types as types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel.server import Server + + +async def list_tools(ctx: ServerRequestContext, params) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="greet", + description="Greet someone by name", + input_schema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + ] + ) + + +async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams +) -> types.CallToolResult: + if params.name == "greet": + name = (params.arguments or {})["name"] + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Hello, {name}!")] + ) + raise ValueError(f"Unknown tool: {params.name}") + + +async def list_resources(ctx: ServerRequestContext, params) -> types.ListResourcesResult: + return types.ListResourcesResult( + resources=[ + types.Resource( + uri="info://version", name="version", description="Server version" + ) + ] + ) + + +async def read_resource( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams +) -> types.ReadResourceResult: + if str(params.uri) != "info://version": + raise ValueError(f"Unknown resource: {params.uri}") + return types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=params.uri, text=json.dumps({"version": "1.0.0"}) + ) + ] + ) + + +async def list_prompts(ctx: ServerRequestContext, params) -> types.ListPromptsResult: + return types.ListPromptsResult( + prompts=[ + types.Prompt( + name="summarize", + description="Summarize text", + arguments=[types.PromptArgument(name="text", required=True)], + ) + ] + ) + + +async def get_prompt( + ctx: ServerRequestContext, params: types.GetPromptRequestParams +) -> types.GetPromptResult: + if params.name != "summarize": + raise ValueError(f"Unknown prompt: {params.name}") + text = (params.arguments or {}).get("text", "") + return types.GetPromptResult( + description="Summarize text", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=f"Summarize:\n\n{text}"), + ) + ], + ) + + +server = Server( + "demo", + on_list_tools=list_tools, + on_call_tool=call_tool, + on_list_resources=list_resources, + on_read_resource=read_resource, + on_list_prompts=list_prompts, + on_get_prompt=get_prompt, +) +``` + +```python After +import json + +from fastmcp import FastMCP + +mcp = FastMCP("demo") + + +@mcp.tool +def greet(name: str) -> str: + """Greet someone by name""" + return f"Hello, {name}!" + + +@mcp.resource("info://version") +def version() -> str: + """Server version""" + return json.dumps({"version": "1.0.0"}) + + +@mcp.prompt +def summarize(text: str) -> str: + """Summarize text""" + return f"Summarize:\n\n{text}" + + +if __name__ == "__main__": + mcp.run() +``` + + + +## What You Gain + +Deleting the handler machinery is the immediate payoff, but the reason to make this move is what becomes available once your server is a FastMCP server. + +[Server composition](/servers/composition) mounts one server inside another, so a surface that grew unwieldy as a single dispatch chain splits into modules developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching, with hooks at whichever level of specificity you need — the cross-cutting concerns that, on the low-level class, meant threading the same code through every handler. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control, and the [OpenAPI integration](/integrations/openapi) generates an entire server from an API specification you already have. [Authentication](/servers/auth/authentication) consolidates the SDK's separate token verifier, authorization-server provider, and `AuthSettings` into a single `auth=` provider, with named providers for GitHub, Google, Auth0, Keycloak, and others. + +The change most likely to affect your daily work is [testing](/servers/testing). FastMCP ships a client that connects to a server object in the same Python process, so a test calls your tools directly — no subprocess, no stdio pipes, no transport to stand up. diff --git a/docs/getting-started/upgrading/from-mcp-sdk-v1.mdx b/docs/getting-started/upgrading/from-mcp-sdk-v1.mdx new file mode 100644 index 000000000..ec5ac5b7e --- /dev/null +++ b/docs/getting-started/upgrading/from-mcp-sdk-v1.mdx @@ -0,0 +1,264 @@ +--- +title: Upgrading from MCP SDK v1 +sidebarTitle: "From MCP SDK v1" +description: Upgrade from FastMCP 1.0, bundled in v1 of the MCP Python SDK, to the standalone FastMCP framework +icon: up +--- + +If your server starts with `from mcp.server.fastmcp import FastMCP`, you're using FastMCP 1.0 — the version bundled with v1 of the `mcp` package. Upgrading to the standalone FastMCP framework is easy. **For most servers, it's a single import change.** + +```python test="skip" +# Before +from mcp.server.fastmcp import FastMCP + +# After +from fastmcp import FastMCP +``` + +That's it. Your `@mcp.tool`, `@mcp.resource`, and `@mcp.prompt` decorators, your `mcp.run()` call, and the rest of your server code all work as-is. + + +**Why upgrade?** FastMCP 1.0 pioneered the Pythonic MCP server experience, and we're proud it was bundled into the `mcp` package. The standalone FastMCP project has since grown into a full framework for taking MCP servers from prototype to production — with composition, middleware, proxy servers, authentication, and much more. Upgrading gives you access to all of that, plus ongoing updates and fixes. + + +## The SDK v2 Transition + +MCP SDK v2 is a substantial, deliberate modernization of the protocol layer, and part of that work rebuilt the high-level server as `MCPServer` under `mcp.server.mcpserver`. `mcp.server.fastmcp` does not exist there — so a FastMCP 1.0 server meets the change the moment its environment resolves `mcp` to v2: + +``` +ModuleNotFoundError: No module named 'mcp.server.fastmcp' +``` + +Often nobody chose that moment. An unpinned `mcp` dependency, a fresh lockfile, or a rebuilt container picks up the new major version and the module your server imports on line one has moved. Nothing is wrong with your code, and nothing is wrong with the SDK — major versions are exactly where a change like this belongs. Your build just crossed it earlier than you planned to. + +Pinning the SDK back restores the old module immediately, with no code changes, and buys you time to choose deliberately: + +```bash +pip install "mcp<2" +``` + +## Two Upgrade Paths + +From here, both directions are reasonable, and which is less work depends on which API you already write. + +**`MCPServer`, the SDK's high-level server**, is a capable, well-designed API and the direct continuation of the SDK's own line. Because it was rebuilt rather than renamed, expect real work: a new class and import, a different decorator call style, and protocol types imported from the standalone `mcp_types` package with snake_case field names. + +**FastMCP** is the import change at the top of this page. It is short for a specific, historical reason: FastMCP 1.0 *is* early FastMCP — it was contributed into the `mcp` package, and the standalone project kept developing that same high-level API. The surface you already write against is the surface FastMCP still offers. FastMCP 4 is itself built on MCP SDK v2, so both paths land you on the same modern protocol layer; FastMCP absorbs the adaptation internally rather than asking your code to do it. + +The claim is narrower than it may sound. It holds for FastMCP 1.0 servers specifically, because of shared lineage — not because one library is better than the other. Both projects are moving the same direction on the same protocol. + +If you have already moved to SDK v2 and write against `MCPServer` today, see [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2). If your server uses the low-level `Server` class rather than the high-level one, see [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1). + +## Install + +FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3: + +```bash +pip install "fastmcp==4.0.0b1" +# or +uv add "fastmcp==4.0.0b1" +``` + +An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease). + +FastMCP depends on the `mcp` package, so the SDK stays installed and importable. What changes is which parts of it you reach for. FastMCP 4 builds on SDK v2, where `mcp.server.fastmcp` is gone — anything you imported from it needs a new home, and the sections below cover that. `mcp.types` still resolves (it aliases the standalone `mcp_types` package), though its fields are snake_case now. Update your import, run your server, and if your tools work, you're done. + + +You are upgrading an MCP server from FastMCP 1.0 (bundled in v1 of the `mcp` package) to standalone FastMCP 4. + +FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1 — it explains every item below, with the replacement code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs. + +For most servers the entire upgrade is the first item. Work through the rest looking for signals, and report only what you actually find. + +THE IMPORT (every server needs this) +- `from mcp.server.fastmcp import FastMCP` → `from fastmcp import FastMCP` +- `from mcp.server.fastmcp import Context` +- `from mcp.server.fastmcp import Image` + +CONSTRUCTOR ARGUMENTS THAT MOVED (all raise TypeError) +- moved to run()/http_app(), and FastMCP names them in the error: host, port, log_level, debug, sse_path, message_path, streamable_http_path, json_response, stateless_http +- moved but rejected with only a generic "unexpected keyword argument", so flag these explicitly: `event_store=` (→ `http_app(event_store=...)`; dropping it silently disables streamable-HTTP resumability), `mount_path=` (→ `http_app(path=...)`), `transport=` (→ `run(transport=...)`), `transport_security=` (→ host/origin settings on `http_app()`), `warn_on_duplicate_tools/_resources/_prompts=` (→ one `on_duplicate=`), `dependencies=` (→ a fastmcp.json file) +- `name`, `instructions`, `website_url`, `icons`, `tools`, `lifespan` carry over unchanged +- note when reporting: FastMCP names the streamable HTTP transport "http", not "streamable-http" + +CONTEXT METHODS WITH CHANGED SIGNATURES (compile fine, fail at runtime) +- `ctx.log(level, data)` → `ctx.log(message, level=...)`, message first +- `ctx.info(data)` / `debug` / `warning` / `error` → take a str message, not arbitrary JSON-serializable data +- `ctx.elicit(..., schema=Model)` → `response_type=Model` +- `ctx.read_resource(uri)` → returns a `ResourceResult`; read `.contents` rather than iterating the return value +- `ctx.report_progress`, `ctx.request_id`, `ctx.client_id` are unchanged + +AUTHENTICATION (the one case where the single import change is NOT enough) +- `token_verifier=` and `auth_server_provider=` — both raise TypeError on FastMCP 4 +- `auth=AuthSettings(...)` — the keyword survives but the value does not: FastMCP's `auth=` takes a FastMCP `AuthProvider`, not the SDK settings object +Report these as a real migration, not a rename: FastMCP consolidates all three into one provider, and ships `JWTVerifier` for tokens you already issue, `RemoteAuthProvider` for delegating to an external authorization server, `OAuthProxy` for wrapping a provider without Dynamic Client Registration, and named providers for GitHub, Google, Auth0, Keycloak, and others. Look up the right one at https://gofastmcp.com/servers/auth/authentication rather than guessing. + +PROMPT RETURN VALUES +- prompt functions returning `PromptMessage`, or `TextContent`-wrapped content +- prompt functions returning raw dicts with "role"/"content" keys — FastMCP 1.0 coerced these silently, standalone FastMCP does not + +OTHER mcp.* IMPORTS +- anything from `mcp.types` — the import path still works in the SDK v2 that FastMCP 4 builds on, but the fields were renamed from camelCase to snake_case +- `from mcp.server.stdio import stdio_server` and any transport boilerplate around it +- `mcp.types.TextContent` / `ImageContent` used to wrap tool return values — FastMCP has friendlier equivalents, so prefer those over keeping the raw protocol types + +DECORATOR RETURN VALUES +- any code reading `.name`, `.description`, or other component attributes off a `@mcp.tool` / `@mcp.resource` / `@mcp.prompt` decorated function. Decorators return the original function now. + +For each item found, show the original line, name what changed, and give the corrected code from the guide. If the only change needed is the import, say so plainly rather than manufacturing work. + + +## What Might Need Updating + +Most servers need nothing beyond the import change. Skim the sections below to see if any apply. + +### Constructor Settings + +If you passed transport settings like `host` or `port` directly to `FastMCP()`, those now belong on `run()`. This keeps your server definition independent of how it's deployed: + +```python test="skip" +from fastmcp import FastMCP + +# Before +mcp = FastMCP("my-server", host="0.0.0.0", port=8080) +mcp.run() + +# After +mcp = FastMCP("my-server") +mcp.run(transport="http", host="0.0.0.0", port=8080) +``` + +Nine arguments move this way, and each raises a `TypeError` naming its own replacement, so you can also just run the server and follow the errors: `host`, `port`, `log_level`, `debug`, `sse_path`, `message_path`, `streamable_http_path`, `json_response`, and `stateless_http`. + +A second group is rejected with only a generic "unexpected keyword argument" and no hint, which makes these the ones worth reading in advance: + +| SDK v1 `FastMCP(...)` | FastMCP 4 | +|---|---| +| `event_store=` | `mcp.http_app(event_store=...)` | +| `mount_path=` | `mcp.http_app(path=...)` | +| `transport=` | `mcp.run(transport=...)` | +| `transport_security=` | `host_origin_protection=`, `allowed_hosts=`, `allowed_origins=` on `http_app()` | +| `warn_on_duplicate_tools=`, `_resources=`, `_prompts=` | a single `on_duplicate=` | +| `dependencies=[...]` | a [`fastmcp.json`](/deployment/server-configuration) configuration file | +| `auth_server_provider=`, `token_verifier=` | a single `auth=` provider — see [Authentication](#authentication) below | + +Dropping `event_store=` rather than moving it is the one to watch: it silently disables streamable-HTTP resumability, so a client that reconnects loses the events it missed instead of replaying them. + +`name`, `instructions`, `website_url`, `icons`, `tools`, and `lifespan` carry over to the constructor unchanged. + +### Authentication + +This is the one case where the import change alone won't do. FastMCP 1.0 exposed the SDK's auth plumbing as three separate constructor arguments — `token_verifier=`, `auth_server_provider=`, and `auth=AuthSettings(...)`. The first two raise `TypeError` on FastMCP 4, and while `auth=` survives as a keyword, its value doesn't: FastMCP expects one of its own `AuthProvider` objects rather than the SDK's settings object. + +The replacement is a single provider carrying the whole configuration, chosen by what you're actually doing: + +| What you were doing | FastMCP provider | +|---|---| +| Validating JWTs you already issue | `JWTVerifier` | +| Delegating to an external authorization server | `RemoteAuthProvider` | +| Wrapping a provider without Dynamic Client Registration | `OAuthProxy` | +| GitHub, Google, Auth0, Keycloak, WorkOS, … | the matching named provider | + +```python +from fastmcp import FastMCP +from fastmcp.server.auth import JWTVerifier + +mcp = FastMCP("my-server", auth=JWTVerifier(jwks_uri="https://example.com/.well-known/jwks.json")) +``` + +See [Authentication](/servers/auth/authentication) for the full set and their configuration. + +### Context Methods + +`from fastmcp import Context` gets you the injected context object, but four of its methods took a different shape in FastMCP 1.0, and a bare import swap leaves calls that compile and then fail: + +| SDK v1 | FastMCP 4 | +|---|---| +| `ctx.log(level, data)` | `ctx.log(message, level=...)` — message is first now | +| `ctx.info(data)` and its `debug`/`warning`/`error` siblings | take a `str` message, where v1 accepted any JSON-serializable value | +| `ctx.elicit(message, schema=Model)` | `ctx.elicit(message, response_type=Model)` | +| `ctx.read_resource(uri)` | returns a `ResourceResult`; the payload is under `.contents` rather than being iterable directly | + +`ctx.report_progress()`, `ctx.request_id`, and `ctx.client_id` are unchanged. + +### Prompts + +If your prompt functions return `mcp.types.PromptMessage` objects or raw dicts with `role`/`content` keys, upgrade them to FastMCP's `Message` class. Or just return a plain string — it's automatically wrapped as a user message. FastMCP 1.0 silently coerced dicts into messages; standalone FastMCP requires typed `Message` objects or strings. + +```python +from fastmcp import FastMCP + +mcp = FastMCP("prompts") + +@mcp.prompt +def review(code: str) -> str: + """Review code for issues""" + return f"Please review this code:\n\n{code}" +``` + +Multi-turn prompts return a list of messages. `Message` takes the text positionally and defaults to the user role, so only the assistant turns need a `role`: + +```python +from fastmcp import FastMCP +from fastmcp.prompts import Message + +mcp = FastMCP("prompts") + +@mcp.prompt +def debug(error: str) -> list[Message]: + """Start a debugging session""" + return [ + Message(f"I'm seeing this error:\n\n{error}"), + Message("I'll help debug that. Can you share the relevant code?", role="assistant"), + ] +``` + +### Other `mcp.*` Imports + +FastMCP 4 builds on MCP SDK v2, which moved the protocol types into a standalone `mcp_types` package and re-exports it as `mcp.types` — so `from mcp.types import X` keeps working. The field names did change, from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, and so on). For everything else SDK v2 changed, see [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3), which covers the same protocol rebuild from the FastMCP side. + +Where FastMCP provides its own API for the same thing, it's worth switching over rather than importing the protocol type: + +| MCP SDK v1 | FastMCP equivalent | +|---|---| +| `mcp.types.TextContent(type="text", text=str(x))` | Just return `x` from your tool | +| `mcp.types.ImageContent(...)` | `from fastmcp.utilities.types import Image` | +| `mcp.types.PromptMessage(...)` | `from fastmcp.prompts import Message` | +| `mcp.server.fastmcp.Context` | `from fastmcp import Context` | +| `from mcp.server.stdio import stdio_server` | Not needed — `mcp.run()` handles transport | + +For protocol types without a FastMCP equivalent, import them from `mcp_types` directly. + +### Decorated Functions + +In FastMCP 1.0, `@mcp.tool` replaced your function with a `FunctionTool` object. Now decorators return your original function unchanged, so decorated functions stay callable for testing, reuse, and composition: + +```python +from fastmcp import FastMCP + +mcp = FastMCP("greeter") + +@mcp.tool +def greet(name: str) -> str: + """Greet someone""" + return f"Hello, {name}!" + +# This works now — the function is still a regular function +assert greet("World") == "Hello, World!" +``` + +Code that reads `.name`, `.description`, or other component attributes off the decorated result needs updating. This is uncommon — most servers never touch the tool object. When you do need the component itself, reach it through the server with `await mcp.get_tool("greet")`. + +## Verifying the Upgrade + +Run your server the way you always have. To confirm every component came across, inspect the server with the FastMCP CLI: + +```bash +fastmcp inspect my_server.py +``` + +The output lists every tool, resource, template, and prompt your server exposes, so a component that failed to register shows up here rather than at the first client call. + +## Looking Ahead + +The MCP ecosystem is evolving fast. Part of FastMCP's job is to absorb that complexity on your behalf — as the protocol and its tooling grow, we do the work so your server code doesn't have to change. The SDK v1 to v2 transition is the clearest example so far: an entire protocol layer was rewritten underneath FastMCP 4, and the servers on this page cross it with one line. diff --git a/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx b/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx new file mode 100644 index 000000000..e25489005 --- /dev/null +++ b/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx @@ -0,0 +1,328 @@ +--- +title: Upgrading from MCP SDK v2 +sidebarTitle: "From MCP SDK v2" +description: Move a server built on the MCP Python SDK v2's MCPServer class to FastMCP +icon: up +--- + +If your server starts with `from mcp.server.mcpserver import MCPServer`, you're using the high-level server API introduced in v2 of the `mcp` package. Moving to FastMCP is a mechanical migration: the two APIs share a lineage, so most of your code carries over with a rename. + +```python +# Before +from mcp.server.mcpserver import MCPServer + +server = MCPServer("my-server") + +@server.tool() +def greet(name: str) -> str: + """Greet someone by name""" + return f"Hello, {name}!" + +# After +from fastmcp import FastMCP + +mcp = FastMCP("my-server") + +@mcp.tool +def greet(name: str) -> str: + """Greet someone by name""" + return f"Hello, {name}!" +``` + +That resemblance is not a coincidence. `MCPServer` is the SDK's successor to FastMCP 1.0, the high-level server that shipped inside SDK v1; FastMCP is the standalone framework that grew from the same starting point. Both derive the protocol layer from your function signatures — type hints become JSON Schema, docstrings become descriptions, return values are serialized for you. What separates them is scope: `MCPServer` is the SDK's ergonomic surface over the protocol, while FastMCP builds on that same SDK v2 and adds the machinery a server needs in production — composition, middleware, proxying, authentication providers, tool transformation, a client, and a testing story. + + +Building on the low-level `Server` class instead? See [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2). Still on SDK v1's `mcp.server.fastmcp.FastMCP`? Your upgrade is a single import — see [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1). + + + +You are migrating an MCP server from the MCP Python SDK v2's high-level `MCPServer` class (`mcp.server.mcpserver`) to FastMCP 4. The two APIs are close relatives, so most of this is mechanical renaming. + +FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2 — it carries the full mapping table and before-and-after code for everything below. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs. + +Then work through the provided code looking for each of these. + +IMPORTS AND CONSTRUCTION +- `MCPServer`, and `Context`, `Image`, `Audio`, `Message` imported from `mcp.server.mcpserver` +- `mcp_types` imports — these are UNCHANGED. FastMCP 4 builds on the same SDK v2, so leave them alone and say so. + +DECORATORS +- `@server.tool()`, `@server.prompt()` — FastMCP takes a bare `@mcp.tool` / `@mcp.prompt` (and still accepts the called form) +- `@server.resource(...)`, `@server.completion()`, `@server.custom_route(...)` + +TRANSPORT +- `run(transport="streamable-http")` — FastMCP names this transport "http" +- `streamable_http_app()`, `sse_app()` + +CONSTRUCTOR ARGUMENTS THAT DO NOT CARRY OVER +- `debug=`, `log_level=` +- `warn_on_duplicate_tools=` / `_resources=` / `_prompts=` +- `dependencies=` +- `title=`, `description=` +- `token_verifier=`, `auth_server_provider=`, `auth=AuthSettings(...)` — FastMCP consolidates all three into one `auth=` provider +- `cache_hints=` +- `extensions=` +- `tools=[...]` (rare — the SDK's `Tool` type is not exported): FastMCP takes plain callables, so pass the underlying functions +These raise TypeError, most naming their replacement. `name`, `version`, `instructions`, `icons`, `website_url`, `lifespan`, `resource_security`, and `request_state_security` carry over unchanged. + +CONTEXT — these ten properties do NOT exist on FastMCP's Context and raise AttributeError if you only swap the import: +- `ctx.mcp_server` → `ctx.fastmcp` +- `ctx.headers` → `get_http_headers()` from `fastmcp.server.dependencies` (a function, not a property) +- `ctx.protocol_version` → `ctx.request_context.protocol_version` +- `ctx.client_capabilities` → read it off `ctx.session` / `ctx.request_context` +- `ctx.notify_tools_changed()`, `notify_resources_changed()`, `notify_prompts_changed()`, `notify_resource_updated()` → `ctx.send_notification(...)` with the matching `mcp_types` notification. FastMCP emits the list-changed ones for you when components change visibility through `ctx.enable_components` / `ctx.disable_components`. +- `ctx.elicit_url` → not the same thing as `ctx.elicit` (that one is form elicitation, with a different signature and wire behavior). The URL flow survives on the raw session as `ctx.session.elicit_url(...)` — use that rather than deleting an OAuth or payment handoff. +- `ctx.close_standalone_sse_stream` → no public FastMCP equivalent, and NOT on `ctx.request_context`. Flag it for the user. +These four exist on both but with DIFFERENT signatures, so a bare import swap compiles and then fails at runtime: +- `ctx.log(level, data)` → `ctx.log(message, level=...)` — the first positional argument is now the message, not the level +- `ctx.info(data)` / `debug` / `warning` / `error` → these take `message` as a string, where the SDK accepted any JSON-serializable `data` +- `ctx.elicit(message, schema=Model)` → `ctx.elicit(message, response_type=Model)` — the keyword was renamed +- `ctx.read_resource(uri)` → still takes a URI, but returns a `ResourceResult` whose payload is under `.contents`, where the SDK returned an iterable of content objects directly. Code that iterates or indexes the return value needs updating. + +Genuinely unchanged: `report_progress`, `request_id`, `client_id`, `input_responses`, `request_state`, `session`, and `request_context`. + +RESOLVERS — the one part that is not a rename, so check for it first +- any `Annotated[T, Resolve(fn)]` parameter, and the resolvers behind it +- resolvers returning `Elicit[...]`, `Sample`, or `ListRoots` +FastMCP has no resolver injection, but the underlying requests survive in a different shape: on a modern connection `Elicit`, `Sample`, and `ListRoots` all ride the guard pattern, where the tool returns an `InputRequiredResult` and the client answers on the next call. Do not tell the user these capabilities are simply unavailable. Flag every resolver with the guide's per-capability reasoning (server-side LLM call is usually better than guard-routed sampling; roots are often simplest as ordinary tool arguments) rather than picking a rewrite yourself. Also note that a resolved parameter is hidden from the tool's input schema, so replacing it with an ordinary argument changes the schema clients see. + +For each item found, show the original code, name what changed, and give the FastMCP equivalent from the guide. Call out anything you could not find a documented replacement for instead of inventing one. + + +## Install + +FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3: + +```bash +pip install "fastmcp==4.0.0b1" +# or +uv add "fastmcp==4.0.0b1" +``` + +An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease). + +FastMCP 4 depends on the MCP SDK v2, so nothing you already import from `mcp_types` moves. That is the practical benefit of migrating at this version rather than an earlier one: you and FastMCP are on the same protocol layer, with the same snake_case field names and the same type package, so the migration touches only the server API. + +## The Mechanical Part + +Most of the work is renaming. This table covers the surfaces a typical `MCPServer` server touches: + +| MCP SDK v2 | FastMCP | +|---|---| +| `from mcp.server.mcpserver import MCPServer` | `from fastmcp import FastMCP` | +| `from mcp.server.mcpserver import Context` | `from fastmcp import Context` | +| `from mcp.server.mcpserver import Image, Audio` | `from fastmcp.utilities.types import Image, Audio` | +| `from mcp.server.mcpserver.prompts.base import Message` | `from fastmcp.prompts import Message` | +| `@server.tool()` | `@mcp.tool` | +| `@server.prompt()` | `@mcp.prompt` | +| `@server.resource("uri://x")` | `@mcp.resource("uri://x")` | +| `@server.completion()` | `@mcp.completion` | +| `@server.custom_route(path, methods)` | `@mcp.custom_route(path, methods)` | +| `server.run(transport="streamable-http")` | `mcp.run(transport="http")` | +| `server.streamable_http_app()` | `mcp.http_app()` | +| `server.sse_app()` | `mcp.http_app(transport="sse")` | +| `ctx.mcp_server` | `ctx.fastmcp` | +| `ctx.headers` | `get_http_headers()` from `fastmcp.server.dependencies` | +| `ctx.protocol_version` | `ctx.request_context.protocol_version` | +| `ctx.client_capabilities` | read it off `ctx.session` | +| `from mcp_types import X` | unchanged | + +Two of these are worth a sentence each. The decorators lose their parentheses: `MCPServer` required `@server.tool()` and raised a `TypeError` telling you so if you wrote `@server.tool`, while FastMCP accepts both forms, so `@mcp.tool` is the idiomatic spelling and `@mcp.tool()` keeps working if you'd rather not touch every line. And the streamable HTTP transport is named `"http"` in FastMCP rather than `"streamable-http"` — the transport is the same, and `mcp.run()` still defaults to stdio. + +Here is a complete server before and after. Nothing in the logic changes: + + + +```python Before +import json +from mcp.server.mcpserver import MCPServer, Context + +server = MCPServer("demo") + +@server.tool() +def greet(name: str) -> str: + """Greet someone by name""" + return f"Hello, {name}!" + +@server.tool() +async def process(items: list[str], ctx: Context) -> str: + """Process a batch of items""" + for i, item in enumerate(items): + await ctx.report_progress(i, len(items)) + return f"Processed {len(items)} items" + +@server.resource("config://app", mime_type="application/json") +def app_config() -> str: + """Application configuration""" + return json.dumps({"debug": False}) + +@server.resource("users://{user_id}/profile") +def profile(user_id: str) -> str: + """User profile by ID""" + return json.dumps({"id": user_id}) + +@server.prompt() +def summarize(text: str) -> str: + """Summarize text""" + return f"Summarize:\n\n{text}" + +if __name__ == "__main__": + server.run(transport="streamable-http") +``` + +```python After +import json +from fastmcp import FastMCP, Context + +mcp = FastMCP("demo") + +@mcp.tool +def greet(name: str) -> str: + """Greet someone by name""" + return f"Hello, {name}!" + +@mcp.tool +async def process(items: list[str], ctx: Context) -> str: + """Process a batch of items""" + for i, item in enumerate(items): + await ctx.report_progress(i, len(items)) + return f"Processed {len(items)} items" + +@mcp.resource("config://app", mime_type="application/json") +def app_config() -> str: + """Application configuration""" + return json.dumps({"debug": False}) + +@mcp.resource("users://{user_id}/profile") +def profile(user_id: str) -> str: + """User profile by ID""" + return json.dumps({"id": user_id}) + +@mcp.prompt +def summarize(text: str) -> str: + """Summarize text""" + return f"Summarize:\n\n{text}" + +if __name__ == "__main__": + mcp.run(transport="http") +``` + + + +## Constructor Arguments + +`FastMCP()` describes your server's identity and behavior; how it gets deployed is decided when you serve it. Several `MCPServer` constructor arguments move accordingly, and each raises a `TypeError` naming its replacement rather than being silently ignored. + +`name`, `version`, `instructions`, `icons`, `website_url`, `lifespan`, `resource_security`, and `request_state_security` all mean what they meant before. The rest map like this: + +| `MCPServer(...)` | FastMCP | +|---|---| +| `debug=True` | `FASTMCP_DEBUG` environment variable | +| `log_level="DEBUG"` | `run_http_async(log_level=...)` or `FASTMCP_LOG_LEVEL` | +| `warn_on_duplicate_tools`, `_resources`, `_prompts` | a single `on_duplicate=` | +| `dependencies=[...]` | a [`fastmcp.json`](/deployment/server-configuration) configuration file | +| `title=`, `description=` | `instructions=` | +| `tools=[Tool, ...]` | `tools=[callable, ...]`, or FastMCP's own `Tool` | +| `resources=[Resource, ...]` | no constructor keyword — register with `@mcp.resource` or `mcp.add_resource()` | +| `subscriptions=` | no equivalent — see below | +| `token_verifier=`, `auth_server_provider=`, `auth=AuthSettings(...)` | a single `auth=` provider | +| `cache_hints={...}` | `cache_ttl=`, `cache_scope=` | +| `extensions=[...]` | `mcp.add_extension(...)` | +| `middleware=[ServerMiddleware, ...]` | `middleware=[Middleware, ...]` — same keyword, different class | + +`middleware=` is the row most likely to be mistaken for a rename. Both constructors take a `middleware=` sequence, but an `MCPServer` wants the SDK's `ServerMiddleware` — one hook wrapping every raw JSON-RPC message — while FastMCP wants its own `Middleware`, which adds typed per-operation hooks (`on_call_tool`, `on_list_tools`, and the rest) on top of the same message-level pass. Keeping the keyword and swapping the base class is the migration; see [Middleware](/servers/middleware). + +Authentication is the largest of these, and it consolidates rather than moves. `MCPServer` exposes the SDK's raw auth plumbing — a token verifier, an authorization-server provider, and an `AuthSettings` object, configured separately. FastMCP takes one `auth=` provider that carries the whole configuration, and ships providers for the common cases: `JWTVerifier` for validating tokens you already issue, `RemoteAuthProvider` for delegating to an external authorization server, `OAuthProxy` for wrapping a provider that lacks Dynamic Client Registration, and named providers for GitHub, Google, Auth0, Keycloak, WorkOS, and others. See [Authentication](/servers/auth/authentication). + +Two rows are worth reading before you delete the argument. `resources=` has no constructor equivalent, so pre-built `Resource` objects need registering through `@mcp.resource` or `mcp.add_resource()` instead — dropping the keyword silently drops the resources with it. And `subscriptions=`, which an `MCPServer` uses to plug in an external pub/sub bus so resource-update notifications reach clients across replicas, has no FastMCP equivalent at all. A multi-replica deployment that relies on it should confirm it can live without cross-replica subscription fan-out before migrating, because a mechanical rename removes that behavior without any error to warn you. + +### Serving HTTP + +Renaming `streamable_http_app()` to `http_app()` is only mechanical for a call with no arguments. The keywords were renamed and regrouped, so an existing call carries arguments `http_app()` does not accept: + +| SDK v2 | FastMCP | +|---|---| +| `streamable_http_app(streamable_http_path=...)` | `http_app(path=...)` | +| `sse_app(sse_path=...)` | `http_app(path=..., transport="sse")` | +| `sse_app(message_path=...)` | no equivalent | +| `transport_security=TransportSecuritySettings(...)` | `host_origin_protection=`, `allowed_hosts=`, `allowed_origins=` | +| `host=...` | pass to `mcp.run(host=...)` instead | + +`json_response`, `stateless_http`, `event_store`, and `retry_interval` keep their names. See [Deploying HTTP servers](/deployment/http) for the host and origin settings. + +### Stricter Arguments + +One behavior change survives the rename and is worth knowing before you migrate. `MCPServer` binds the arguments it recognizes and ignores the rest, so a call carrying an unexpected key succeeds. FastMCP declares `"additionalProperties": false` on every generated schema and enforces it, so the same call fails: + +```python test="skip" +# Against MCPServer: succeeds, "extra" ignored. +# Against FastMCP: raises, "extra" is not a parameter of greet(). +await client.call_tool("greet", {"name": "World", "extra": "surprise"}) +``` + +For most servers this is an improvement that costs nothing — a caller sending keys your tool never reads was already a bug. It matters if a client in your fleet passes extra metadata alongside real arguments, since those calls start failing the moment you migrate. Accept the extras explicitly as optional parameters if you need to keep them working. + +## Asking for Input + +This is the one part of the migration that is not a rename, so read it before you start if your tools use resolvers. + +`MCPServer` asks the client for things through dependency-injection resolvers. A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body, and the resolver can return a request marker — `Elicit[T]` to ask the user, `Sample` to borrow the client's model, `ListRoots` to fetch its roots — which the framework turns into the right wire interaction for whichever protocol era the connection negotiated: + +```python +from typing import Annotated +from pydantic import BaseModel +from mcp.server.mcpserver import MCPServer, Resolve, Elicit + +server = MCPServer("booking") + + +class Destination(BaseModel): + destination: str + + +def ask_destination() -> Elicit[Destination]: + return Elicit("Where would you like to fly?", Destination) + + +@server.tool() +def book_flight(dest: Annotated[Destination, Resolve(ask_destination)]) -> str: + """Book a flight""" + return f"Booked to {dest.destination}" +``` + +FastMCP has no equivalent annotation, and it makes the protocol era explicit instead of hiding it. Which replacement you want depends on which era your clients speak. + +On **handshake-era connections** (≤ 2025-11-25), a running tool asks the user directly with `ctx.elicit()`, and the call blocks until the answer arrives. Where the resolver returned a value or aborted the call, `ctx.elicit()` hands you the outcome to branch on, so declining and cancelling become cases your tool answers for itself: + +```python +from fastmcp import FastMCP, Context + +mcp = FastMCP("booking") + + +@mcp.tool +async def book_flight(ctx: Context) -> str: + """Book a flight""" + result = await ctx.elicit("Where would you like to fly?", response_type=str) + if result.action == "accept": + return f"Booked to {result.data}" + return "Booking cancelled" +``` + +On the **modern protocol** (2026-07-28), server-initiated requests are gone from the wire, so a tool asks by *returning* a description of what it needs. The client answers and calls the tool again with the answer attached, and the tool re-runs from the top. This is the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), and it reads the answers off `ctx.input_responses`. + +The two are era-gated in both directions: `ctx.elicit()` raises on a modern connection, and a guard result raises on a handshake one. A server that must serve both branches on `ctx.request_context.protocol_version`. See [Elicitation](/servers/elicitation#which-approach-to-use) for both shapes side by side. + +Resolvers that return `Sample` or `ListRoots` have no *injected* equivalent — FastMCP has no `ctx.sample()` or `ctx.list_roots()` — but the underlying request survives, so this is a change of shape rather than a loss of capability. On a modern connection both ride the same guard pattern as elicitation: the tool returns an `InputRequiredResult` describing the sampling or roots request, and the client answers on the next call. + +Which shape you want differs by capability. For **roots**, the guard route is the natural replacement, since one round buys the whole answer — and taking the paths as ordinary tool arguments is simpler still whenever the caller can supply them. For **generation**, prefer [calling an LLM from your server](/servers/sampling) with your own API key: your tool then behaves identically for every client, including the many that never implemented sampling, and you avoid paying a full request-response cycle per generation step. Reach for the guard route when using the *caller's* model is specifically the point. + +One schema detail is easy to miss during the rewrite. A resolved parameter never appears in the tool's input schema — `book_flight` above advertises no arguments at all. When you replace a resolver with an explicit tool argument, the schema the client sees gains a field, which is usually what you want but is a visible change to your tool's contract. + +## What You Gain + +The migration is worth doing for what sits on the other side of it. FastMCP is a framework rather than a protocol surface, and these are the capabilities that most often motivate the move: + +[Server composition](/servers/composition) mounts one server inside another, so a large surface splits into modules that are developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching, with hooks at whichever level of specificity you need. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control. The [OpenAPI integration](/integrations/openapi) generates a whole server from an existing API specification. [Tool transformation](/servers/transforms/transforms) rewrites the tools a server exposes — renaming, hiding, and reshaping arguments — without touching the code that defines them. + +FastMCP also ships a [client](/clients/client), which `MCPServer` has no counterpart for. It speaks every transport, drives both protocol eras, and connects to a server object in-process — so [testing](/servers/testing) a server means calling its tools in the same Python process, with no subprocess and no network. diff --git a/docs/getting-started/upgrading/from-mcp-sdk.mdx b/docs/getting-started/upgrading/from-mcp-sdk.mdx deleted file mode 100644 index 1954ceb72..000000000 --- a/docs/getting-started/upgrading/from-mcp-sdk.mdx +++ /dev/null @@ -1,166 +0,0 @@ ---- -title: Upgrading from the MCP SDK -sidebarTitle: "From MCP SDK" -description: Upgrade from FastMCP in the MCP Python SDK to the standalone FastMCP framework -icon: up ---- - -If your server starts with `from mcp.server.fastmcp import FastMCP`, you're using FastMCP 1.0 — the version bundled with v1 of the `mcp` package. Upgrading to the standalone FastMCP framework is easy. **For most servers, it's a single import change.** - -```python -# Before -from mcp.server.fastmcp import FastMCP - -# After -from fastmcp import FastMCP -``` - -That's it. Your `@mcp.tool`, `@mcp.resource`, and `@mcp.prompt` decorators, your `mcp.run()` call, and the rest of your server code all work as-is. - - -**Why upgrade?** FastMCP 1.0 pioneered the Pythonic MCP server experience, and we're proud it was bundled into the `mcp` package. The standalone FastMCP project has since grown into a full framework for taking MCP servers from prototype to production — with composition, middleware, proxy servers, authentication, and much more. Upgrading gives you access to all of that, plus ongoing updates and fixes. - - -## Install - -```bash -pip install --upgrade fastmcp -# or -uv add fastmcp -``` - -FastMCP includes the `mcp` package as a dependency, so you don't lose access to anything. Update your import, run your server, and if your tools work, you're done. - - -You are upgrading an MCP server from FastMCP 1.0 (bundled in the `mcp` package v1) to standalone FastMCP 4. Analyze the provided code and identify every change needed. The full upgrade guide is at https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context. - -STEP 1 — IMPORT (required for all servers): -Change "from mcp.server.fastmcp import FastMCP" to "from fastmcp import FastMCP". - -STEP 2 — CONSTRUCTOR KWARGS (only if FastMCP() receives transport settings): -FastMCP() no longer accepts: host, port, log_level, debug, sse_path, streamable_http_path, json_response, stateless_http. -Fix: pass these to run() instead. -Before: `mcp = FastMCP("server", host="0.0.0.0", port=8080); mcp.run()` -After: `mcp = FastMCP("server"); mcp.run(transport="http", host="0.0.0.0", port=8080)` - -STEP 3 — PROMPTS (only if using PromptMessage directly or returning dicts): -mcp.types.PromptMessage is replaced by fastmcp.prompts.Message. -Before: `PromptMessage(role="user", content=TextContent(type="text", text="Hello"))` -After: `Message("Hello")` — role defaults to "user", accepts plain strings. -Also: if prompts return raw dicts like `{"role": "user", "content": "..."}`, these must become Message objects or plain strings. -The MCP SDK's FastMCP 1.0 silently coerced dicts; standalone FastMCP requires typed returns. - -STEP 4 — OTHER MCP IMPORTS (only if importing from mcp.* directly): -FastMCP now builds on MCP SDK v2, which removed the `mcp.types` module — protocol types live in the standalone `mcp_types` package. Update any `from mcp.types import X` to `from mcp_types import X`. Prefer FastMCP's own APIs where equivalents exist: -- mcp_types.TextContent for tool returns → just return plain Python values (str, int, dict, etc.) -- mcp_types.ImageContent → fastmcp.utilities.types.Image -- from mcp.server.stdio import stdio_server → not needed, mcp.run() handles transport - -STEP 5 — DECORATORS (only if treating decorated functions as objects): -@mcp.tool, @mcp.resource, @mcp.prompt now return the original function, not a component object. Code that accesses .name or .description on the decorated result needs updating. Set FASTMCP_DECORATOR_MODE=object temporarily to restore v1 behavior (this compat setting is itself deprecated). - -For each issue found, show the original line, explain what changed, and provide the corrected code. - - -## What Might Need Updating - -Most servers need nothing beyond the import change. Skim the sections below to see if any apply. - -### Constructor Settings - -If you passed transport settings like `host` or `port` directly to `FastMCP()`, those now belong on `run()`. This keeps your server definition independent of how it's deployed: - -```python -# Before -mcp = FastMCP("my-server", host="0.0.0.0", port=8080) -mcp.run() - -# After -mcp = FastMCP("my-server") -mcp.run(transport="http", host="0.0.0.0", port=8080) -``` - -If you pass the old kwargs, you'll get a clear `TypeError` with a migration hint. - -### Prompts - -If your prompt functions return `mcp.types.PromptMessage` objects or raw dicts with `role`/`content` keys, you'll need to upgrade to FastMCP's `Message` class. Or just return a plain string — it's automatically wrapped as a user message. The MCP SDK's bundled FastMCP 1.0 silently coerced dicts into messages; standalone FastMCP requires typed `Message` objects or strings. - -```python -from fastmcp import FastMCP - -mcp = FastMCP("prompts") - -@mcp.prompt -def review(code: str) -> str: - """Review code for issues""" - return f"Please review this code:\n\n{code}" -``` - -For multi-turn prompts: - -```python -from fastmcp.prompts import Message - -@mcp.prompt -def debug(error: str) -> list[Message]: - """Start a debugging session""" - return [ - Message(f"I'm seeing this error:\n\n{error}"), - Message("I'll help debug that. Can you share the relevant code?", role="assistant"), - ] -``` - -### Other `mcp.*` Imports - -FastMCP now builds on MCP SDK v2. The `mcp.types` module no longer exists — protocol types moved to a standalone `mcp_types` package, and the field names were renamed from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, and so on). Update `from mcp.types import X` to `from mcp_types import X`. For the full picture, see [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3). - -Where FastMCP provides its own API for the same thing, it's worth switching over: - -| mcp Package | FastMCP Equivalent | -|---|---| -| `mcp.types.TextContent(type="text", text=str(x))` | Just return `x` from your tool | -| `mcp.types.ImageContent(...)` | `from fastmcp.utilities.types import Image` | -| `mcp.types.PromptMessage(...)` | `from fastmcp.prompts import Message` | -| `from mcp.server.stdio import stdio_server` | Not needed — `mcp.run()` handles transport | - -For protocol types without a FastMCP equivalent, import them from `mcp_types` directly. - -### Decorated Functions - -In FastMCP 1.0, `@mcp.tool` returned a `FunctionTool` object. Now decorators return your original function unchanged — so decorated functions stay callable for testing, reuse, and composition: - -```python -@mcp.tool -def greet(name: str) -> str: - """Greet someone""" - return f"Hello, {name}!" - -# This works now — the function is still a regular function -assert greet("World") == "Hello, World!" -``` - -If you have code that accesses `.name`, `.description`, or other attributes on the decorated result, that will need updating. This is uncommon — most servers don't interact with the tool object directly. If you need the old behavior temporarily, set `FASTMCP_DECORATOR_MODE=object` to restore it (this compatibility setting is itself deprecated and will be removed in a future release). - -## Verify the Upgrade - -```bash -# Install -pip install --upgrade fastmcp - -# Check version -fastmcp version - -# Run your server -python my_server.py -``` - -You can also inspect your server's registered components with the FastMCP CLI: - -```bash -fastmcp inspect my_server.py -``` - -## Looking Ahead - -The MCP ecosystem is evolving fast. Part of FastMCP's job is to absorb that complexity on your behalf — as the protocol and its tooling grow, we do the work so your server code doesn't have to change. diff --git a/docs/getting-started/whats-new.mdx b/docs/getting-started/whats-new.mdx index e4bed506c..b2dfffb8d 100644 --- a/docs/getting-started/whats-new.mdx +++ b/docs/getting-started/whats-new.mdx @@ -8,12 +8,12 @@ icon: sparkles FastMCP 4 is a major version because its engine changed. The framework is now built on the MCP Python SDK v2, a ground-up rebuild of the protocol layer, and on that foundation it adds a new protocol era, first-class extensions, stateless state, enterprise identity, and more. Most FastMCP 3 servers run on it untouched — the major version signals how much moved underneath, and what that movement unlocks. -FastMCP 4 is in **alpha**. Pin an exact version and expect sharp edges. +FastMCP 4 is in **beta**. Pin an exact version and expect sharp edges. See [Install the v4 prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease). ## Built on the MCP Python SDK v2 -The defining change in FastMCP 4 is the one you mostly can't see. The MCP Python SDK v2 rewrote the protocol layer end to end: it split the protocol types into a standalone `mcp_types` package, renamed every wire field from camelCase to snake_case, replaced the server's request-handling model, and made server-side middleware and multi-era serving first-class. FastMCP absorbs nearly all of it — your reads stay working through a compatibility bridge, and the handful of changes left in your code are mechanical. +The defining change in FastMCP 4 is the one you mostly can't see. The MCP Python SDK v2 rewrote the protocol layer end to end: it moved the protocol types into a standalone `mcp_types` package that stays importable as `mcp.types`, renamed every model field from camelCase to snake_case in Python, replaced the server's request-handling model, and made server-side middleware and multi-era serving first-class. FastMCP absorbs nearly all of it — your reads stay working through a compatibility bridge, and the handful of changes left in your code are mechanical. The major version is the signal. Even where your surface is unchanged, the behavior underneath is substantially different, and bumping to 4.0 is how we tell you that plainly rather than slipping a new engine in under a patch release. @@ -23,9 +23,11 @@ The rebuild also pulls the protocol's recent evolution forward in a single step. A FastMCP 4 server answers clients across the protocol transition from one deployment. The MCP SDK negotiates the era per connection — the sessionless `2026-07-28` protocol for clients that have moved forward, the session-based handshake for everyone else — and any replica behind a plain load balancer can serve a modern request. This supersedes FastMCP's earlier "latest protocol only" stance: you adopt the new protocol without forking your deployment or gating clients by version. -The same negotiation runs from the client, and its default flipped. A plain `Client(url)` now probes for the modern protocol and adopts it when the server offers it, falling back to the handshake otherwise — where every earlier FastMCP version pinned the handshake outright. That flip is what brings the modern capabilities within reach of ordinary client code: a task-enabled tool hands back a handle to poll, and multi-round-trip elicitation resolves across successive requests, neither requiring the caller to opt in. Set `mode="legacy"` to pin the handshake when you need the session-based back-channel or the classic `initialize` result. See [Protocol negotiation](/clients/client#protocol-negotiation). +The same negotiation runs from the client, and its default flipped. A plain `Client(url)` now probes for the modern protocol and adopts it when the server offers it, falling back to the handshake otherwise — where every earlier FastMCP version pinned the handshake outright. That flip is what brings the modern capabilities within reach of ordinary client code: a task-enabled tool hands back a handle to poll, and multi-round-trip elicitation resolves across successive requests, neither requiring the caller to opt in. Set `mode="legacy"` to pin the handshake when you need the session-based back-channel or the classic `initialize` result. Once connected, `client.protocol_version`, `client.server_info`, `client.server_capabilities`, and `client.instructions` read the same regardless of which era you negotiated — code that inspects the connection no longer branches on how it got there. See [Protocol negotiation](/clients/client#protocol-negotiation). -The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577). Imperative `ctx.elicit` and `ctx.list_roots` move to a request-shaped pattern on modern connections, and server-initiated sampling — which has no such replacement — is [deprecated](/servers/sampling). Everything else about writing a server is unchanged. +The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577), and FastMCP 4's server API reflects that. `ctx.elicit` moves to a request-shaped pattern that works on modern connections: the tool returns a description of the input it needs, and the client answers with a fresh call. `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` are gone from the API, because each of them pushed a request down a live connection and a method that only works against old clients is a trap. + +Both capabilities survive in the same request-shaped form. Asking for roots that way is the natural replacement, since one round trip buys the whole answer. Generation usually belongs in the server instead, because a loop of asking rounds spends the round-trip budget over and over — [call an LLM from your server](/servers/sampling). Logging is untouched: `ctx.info` and its siblings are notifications, and notifications ride the response stream on every era. Everything else about writing a server is unchanged. ## State without a session @@ -47,7 +49,7 @@ When a client offers autocomplete for a prompt argument or a resource-template p ```python from fastmcp import FastMCP -from mcp_types import PromptReference +from mcp.types import PromptReference mcp = FastMCP("Docs") @@ -84,6 +86,47 @@ mcp = FastMCP("Internal API", auth=auth) The asserted subject flows into the normal auth context, so tools read it through `get_access_token()` like any other identity. See [Identity Assertion](/servers/auth/oauth-proxy#identity-assertion-sep-990). +Authorizing a caller by role is a related, provider-agnostic need. Scopes are standardized, so `require_scopes` behaves the same everywhere, but roles and groups are not part of OIDC and every provider files them under a different claim. `require_roles` handles the comparison and takes an `extract` callable naming where to look, so Keycloak's `realm_access.roles`, Cognito's `cognito:groups`, and Auth0's per-tenant namespaced claims all work without FastMCP guessing. + +```python +from fastmcp import FastMCP +from fastmcp.server.auth import require_roles + +mcp = FastMCP("Internal API") + +@mcp.tool(auth=require_roles("admin", extract=lambda c: c["realm_access"]["roles"])) +def rotate_credentials() -> str: + """Only callable by a caller holding the 'admin' role.""" + return "Rotated" +``` + +This illustrates the check in isolation — enforcing it for real needs an HTTP-transport server with a token-validating `auth` provider configured (a `JWTVerifier`, a `RemoteAuthProvider`, or a provider built on one, such as `KeycloakAuthProvider`, all expose claims directly), since STDIO has no OAuth concept and skips every check. See [Authorization](/servers/authorization#require_roles) for the full picture. + +The client side of enterprise auth arrived too. Not every FastMCP client has a user behind it — a backend service, a scheduled job, one MCP server calling another — and `ClientCredentialsOAuthProvider` authenticates one of those to a protected server with the OAuth 2.0 client-credentials grant: no browser, no redirect, no consent screen. + +```python +import asyncio + +from fastmcp import Client +from fastmcp.client.auth import ClientCredentialsOAuthProvider + +auth = ClientCredentialsOAuthProvider( + client_id="my-client-id", + client_secret="my-client-secret", + scopes=["read", "write"], +) + + +async def main(): + async with Client("https://example.com/mcp", auth=auth) as client: + await client.list_tools() + + +asyncio.run(main()) +``` + +See [Machine-to-Machine Authentication](/clients/auth/client-credentials). + ## Faster and safer Two more capabilities arrive by default. Response caching (SEP-2549) lets a server stamp freshness hints on its results that a caching [client](/clients/client#response-caching) reuses without a round trip, and a distributed `KeyValueResponseCacheStore` backs that cache with Redis or any key-value store, so a fleet of clients or proxy replicas shares fills. @@ -96,4 +139,8 @@ mcp = FastMCP("Weather", cache_ttl=300, cache_scope="public") Security tightened in the same release: every templated resource screens its parameters for path traversal, absolute paths, and null bytes before the handler runs — [path security](/servers/resources#path-security) on by default, covering mounted and proxied templates too. +The OAuth flow got more precise as well. Dynamic Client Registration now honors a client's declared `application_type` (SEP-837): the permissive loopback and app-scheme callbacks MCP clients rely on stay the default for `"native"`, while a client that registers as `"web"` is held to stricter browser-app redirect rules. And when `AuthMiddleware` denies a call specifically for a missing scope, it raises `InsufficientScopeError` naming exactly which scopes would fix it (SEP-2350), so a caller re-authorizes precisely instead of retrying blind. See [Application Type](/servers/auth/oauth-proxy#application-type-web-vs-native) and [Signaling Scope Shortfalls](/servers/authorization#signaling-scope-shortfalls). + +A gateway or load balancer in front of your server can now route a request without parsing its JSON-RPC body: on a modern connection, FastMCP's client attaches the method, target name, and opted-in argument values as HTTP headers (SEP-2243), so an intermediary dispatches on headers alone. See [Gateway Routing Headers](/deployment/http#gateway-routing-headers). + When you're ready to move a server to v4, [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) walks through every change and what it looks like in practice. diff --git a/docs/integrations/auth0.mdx b/docs/integrations/auth0.mdx index 7047ca850..ce2dcc670 100644 --- a/docs/integrations/auth0.mdx +++ b/docs/integrations/auth0.mdx @@ -16,7 +16,7 @@ FastMCP supports two Auth0 integration paths: ## Auth for MCP (DCR) - + This path uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern. Auth0 acts as the authorization server; FastMCP is the resource server. diff --git a/docs/integrations/chatgpt.mdx b/docs/integrations/chatgpt.mdx index e999dd5af..23249f92c 100644 --- a/docs/integrations/chatgpt.mdx +++ b/docs/integrations/chatgpt.mdx @@ -95,7 +95,7 @@ The connector must be explicitly enabled in each chat session through Developer Use `annotations=ToolAnnotations(readOnlyHint=True)` to skip confirmation prompts for read-only tools: ```python -from mcp_types import ToolAnnotations +from mcp.types import ToolAnnotations @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) def get_status() -> str: diff --git a/docs/integrations/descope.mdx b/docs/integrations/descope.mdx index f0357f333..fbe1c2b22 100644 --- a/docs/integrations/descope.mdx +++ b/docs/integrations/descope.mdx @@ -83,6 +83,8 @@ mcp = FastMCP(name="My Descope Protected Server", auth=auth_provider) ### Scope discovery and validation + + When both `scopes_supported` and `required_scopes` are omitted, `DescopeProvider` discovers `scopes_supported` lazily from the OpenID configuration and advertises them to MCP clients. Provider construction remains network-free, and a transient discovery failure is retried on a later metadata request. Set both options when clients should request a broader set of scopes than the server requires on every token: diff --git a/docs/integrations/openapi.mdx b/docs/integrations/openapi.mdx index f6fb1a13f..4769f6576 100644 --- a/docs/integrations/openapi.mdx +++ b/docs/integrations/openapi.mdx @@ -452,4 +452,21 @@ FastMCP handles array parameters according to OpenAPI specifications: ### Headers -Header parameters are automatically converted to strings and included in the HTTP request. \ No newline at end of file +Header parameters are automatically converted to strings and included in the HTTP request. + +### Composed Request Bodies + +A request body becomes a flat set of tool arguments, which is the shape LLM tool-calling APIs fill in most reliably. Schemas composed with `allOf` are resolved first, following `$ref` members, so fields inherited from a parent schema appear alongside the ones a schema declares itself. + +Schemas that use a `discriminator` are flattened the same way. FastMCP merges in the fields of every subtype named in the discriminator's `mapping`, marks them optional, and names the accepted values on the discriminator's own description. Given a `Pet` body discriminated by `petType` and mapped onto `Cat` and `Dog`, the tool takes the discriminator plus whichever fields that variant uses: + +```python +await client.call_tool("create_pet", { + "petType": "cat", + "meowVolume": 11, +}) +``` + +The discriminator stays required; every variant field is optional, because only one variant applies to any given call. + +This trades local strictness for a schema models complete accurately. The generated schema permits any combination of variant fields, so sending `packSize` with `petType: "cat"` passes FastMCP's validation and is rejected by the API itself, exactly as it would be for any other HTTP client. Where two variants declare the same field differently, the declarations are combined with `anyOf` so that neither variant's constraints are advertised as applying to both. \ No newline at end of file diff --git a/docs/more/faq.mdx b/docs/more/faq.mdx index d2bbbe05e..29b0f7e89 100644 --- a/docs/more/faq.mdx +++ b/docs/more/faq.mdx @@ -1,9 +1,116 @@ --- title: FAQ -description: Answers to common questions about installing and using FastMCP +description: Direct answers to the questions that come up most often about FastMCP 4, the protocol eras, and installation icon: circle-question --- +## Do I need to change my server code for FastMCP 4? + +Most servers run untouched. The defining change in FastMCP 4 is its engine — the MCP Python SDK v2 — and FastMCP absorbs nearly all of it for you, including the wire-wide rename from camelCase to snake_case, which is bridged so your existing reads keep working. + +Most of what does reach your code fails loudly at import or call time, and the fix is mechanical: `McpError(ErrorData(...))` becomes `McpError(code=..., message=...)`, custom `httpx` clients handed to a transport become `httpx2`, and `ctx.sample()` and `ctx.list_roots()` are gone. + +One change is silent, so go looking for it: an `except httpx.ConnectError:` around a FastMCP call still imports and still type-checks, because `httpx` usually remains installed through some other dependency — but FastMCP now raises the `httpx2` exception, so the handler simply stops matching and your fallback quietly never runs. Grep for `except httpx.` and move those to `httpx2`. [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) covers each one and ends with a checklist. + +## Why does my client connect with a different protocol version than before? + +`fastmcp.Client` defaults to `mode="auto"` as of FastMCP 4, so it negotiates the newest era both sides speak rather than pinning the handshake. Over streamable HTTP or stdio to a FastMCP server that means the sessionless `2026-07-28` protocol, where FastMCP 3 connected at `2025-11-25`. + +Two transports are exceptions: SSE predates the sessionless era and cannot carry it, and a multi-server `MCPConfigTransport` mounts each backend behind a legacy-era composite. Under `mode="auto"` the client recognizes both and settles on the handshake without probing, so seeing `2025-11-25` there is correct rather than a negotiation failure. Pinning a modern version explicitly on either skips that substitution and asks the transport for something it cannot serve, so leave them on auto or legacy. + +The client probes `server/discover` and adopts the modern protocol when the server answers, falling back to the `initialize` handshake for anything that is not positive evidence of a modern peer — so a mixed fleet of servers still connects. Pin the old behavior per client with `Client(url, mode="legacy")`. See [Protocol negotiation](/clients/client#protocol-negotiation). + +## What are the two protocol eras, and which one does my server speak? + +Both. A FastMCP server serves every era from one deployment and one URL, and the SDK negotiates per connection — the client picks, not the server. + +The *handshake* era (`2025-11-25` and earlier) opens each connection with `initialize` and holds a session, which gives the server a back-channel it can push requests down. The *modern* era (`2026-07-28`) is sessionless: the client learns what the server offers through `server/discover`, every request stands alone, and there is no back-channel. Inside a tool, `ctx.request_context.protocol_version` tells you which era the current call arrived on; on the client, `client.protocol_version` reports it after connecting. + +## Can FastMCP 4 talk to older clients and servers? + +Yes, in both directions, with no configuration. A FastMCP 4 server answers a handshake-era client and a modern one from the same process: the old client sends `initialize` and gets a session id, the modern client discovers and stays stateless. + +A FastMCP 4 client is equally happy against an old server, because `mode="auto"` falls back to the handshake when discovery finds no modern peer. The client-side handlers for server-initiated capabilities are all still there too — passing `sampling_handler=` or `roots=` answers a legacy server's requests exactly as before, which is what a modern client needs in order to interoperate. See [client sampling](/clients/sampling) and [client roots](/clients/roots). + +## When should I pin `mode="legacy"`? + +Pin it when your code depends on the session the handshake creates: `client.ping()` and `transport.get_session_id()` have no modern equivalent, since a sessionless connection has neither a live back-channel to ping nor an id to hold. It is also the escape hatch when a server misbehaves under discovery or you need the classic `initialize` result object. + +You do not need to pin it just because you registered a `sampling_handler`, `roots=`, or an `elicitation_handler`. None of the three require the handshake on their own: `mode="auto"` reaches whichever era the connection negotiates, and on a modern connection a tool can still exercise any of them through the guard pattern — it manually returns an `InputRequiredResult` embedding the request, and the same handler you already registered answers it. [Roots](/clients/roots) and [elicitation](/clients/elicitation) document this pattern directly; [sampling](/clients/sampling) works through the identical mechanism, though calling an LLM directly from the server is the recommended path there rather than a round trip for it. + +Pinning is per client, not a deployment setting: `Client(url, mode="legacy")`. The trade runs the other way as well — [background tasks](/clients/tasks) are modern-only, so a legacy client never triggers one and a task-enabled tool simply runs synchronously. + +## Why did my `ctx.sample()` code stop working? + +`ctx.sample()` and `ctx.sample_step()` are not part of FastMCP 4. Calling either raises `AttributeError` on every protocol era, and `FastMCP(sampling_handler=...)` raises `TypeError` naming the migration. + +Sampling was a server-to-client *request*: the server sent `sampling/createMessage` and blocked until an answer came back down the session. The modern protocol has no server-to-client request direction at all, so the pushed form has nowhere to go. + +The asking survives in a different shape. A tool can return an `InputRequiredResult` carrying a `CreateMessageRequest`; the client answers it through the same `sampling_handler` it already registers, and your tool runs again with the completion. Reach for that when using *the caller's* model is the point. Otherwise put generation in your server — hold a provider API key and call the model directly, which has the side benefit that your tool behaves identically for every client, including the many that never implemented sampling. [Sampling](/servers/sampling) shows both. + +## What happened to `ctx.list_roots()`? + +Removed, for the same reason as sampling: `roots/list` was a server-to-client request, and the modern protocol has no channel to send one. + +Take the paths you need as ordinary tool arguments. The agent already knows which directory it is working in, and an explicit argument is visible in the tool's schema instead of hidden in a protocol round-trip. When the caller genuinely has to be asked mid-run, the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) carries a roots request in its `input_requests` map alongside elicitation, and `fastmcp.Client` answers it from the `roots=` you already configured. + +## Why does `ctx.info()` still work when sampling doesn't? + +Because logging is a *notification* and sampling was a *request*. A notification is fire-and-forget: your server emits it down the response stream the caller already opened, and nothing has to be held open on the server's behalf. A request needs an answer to come back the other way, which requires a live connection the server can reach into. + +The modern protocol kept every server notification — `notifications/message`, `notifications/progress`, and the list-changed family — and removed the server-to-client request direction entirely. So `ctx.info()`, `ctx.debug()`, and `ctx.report_progress()` reach the client mid-call on every era, while sampling and roots have no era-agnostic form and were dropped. [Sampling](/servers/sampling#requests-and-notifications) works through the distinction in full. + +You may see an `MCPDeprecationWarning` from the SDK about the logging capability being deprecated as of `2026-07-28`. It refers to the capability declaration, not to the notification, and delivery is unaffected. + +## Why can't I call `client.set_logging_level()` anymore? + +On a modern connection it raises, because `logging/setLevel` is not in the `2026-07-28` protocol. The method asked the server to remember a level for the rest of the session, and a sessionless protocol has nowhere to keep that. + +The messages themselves are unaffected — the server still sends whatever its own configuration allows. Filter on the receiving side in your `log_handler`, which sees each message's `level` field. See [Client Logging](/clients/logging). On a handshake-era connection (`Client(url, mode="legacy")`) the call works as before. + +Receiving-side filtering only narrows what already arrives. A server that sets `FastMCP(client_log_level="error")` drops anything below that threshold before it reaches the wire, and a modern client has no way to ask for the missing levels — the server operator has to lower `client_log_level` for them to be sent at all. + +## What replaces elicitation on the modern protocol? + +The guard pattern. Rather than pausing mid-execution to ask, a tool *returns* an `InputRequiredResult` describing what it needs. That round completes normally, the client collects the answer, and it calls the tool again with the answer attached. Any state you carry between rounds is sealed by the framework before it reaches the wire, so the client holds an opaque token it cannot read or forge. + +`ctx.elicit()` still works on handshake-era connections and raises on modern ones, so a server that must serve both eras needs both paths. `fastmcp.Client` drives whichever the connection negotiated with no extra wiring on your side. See [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol). + +## Why doesn't my middleware's `on_initialize` hook run? + +Because the modern protocol has no `initialize` request. The hook fires on handshake-era connections and never on modern ones, and since `Client` now defaults to `mode="auto"`, that is the common case against a FastMCP 4 server. + +Work that must happen once per process belongs in the server [lifespan](/servers/lifespan). Per-request work such as an auth check belongs in `on_request` or a specific operation hook, both of which run on every era — on a modern connection `on_request` sees `server/discover` where a handshake connection sees `initialize`. See [Middleware](/servers/middleware). + +## Why doesn't state I set in one tool call show up in the next? + +On a modern connection every request is a fresh connection, so `ctx.set_state` lives only for the duration of the call that wrote it. The same code persists state across calls on a handshake-era connection, which is why it appears to break the moment a client negotiates `2026-07-28`. + +[Session state](/servers/sessions) is the durable answer, following MCP's own decision to move session semantics up into the application. Declare a `UserSession` parameter and FastMCP injects one bucket of stored state keyed to the authenticated user, with nothing to pass around. Declare a `SessionId` argument when a single user needs several independent sessions, and the caller mints an id with `create_session` and supplies it on each call — register `mcp.add_provider(SessionProvider())` first, since `create_session` doesn't exist until a `SessionProvider` contributes it. Both store server-side and key to the authenticated caller's identity, so a handle is inert in anyone else's hands. + +That isolation comes from authentication, not from the id. On an unauthenticated server there is no principal to key on, so every session shares one anonymous namespace and a `SessionId` becomes a bearer capability — anyone holding it can read and write that state. Treat unauthenticated sessions as single-tenant or trusted-network only; `UserSession` sidesteps the question by requiring an authenticated principal outright. + +## How do I run background tasks now? + +The same way, plus one registration. `@mcp.tool(task=True)` is still the authoring surface and [Docket](https://github.com/chrisguidry/docket) still runs the work. What changed is underneath: tasks left the core MCP spec and returned as the `io.modelcontextprotocol/tasks` extension (SEP-2663), which FastMCP implements in the optional `fastmcp-tasks` package. + +Install `fastmcp[tasks]` and register the extension on your server. A `task=True` tool on a server with no tasks extension refuses to start and names the fix, so a missing registration is impossible to ship by accident. + +```python +from fastmcp import FastMCP +from fastmcp_tasks import TasksExtension + +mcp = FastMCP("MyServer") +mcp.add_extension(TasksExtension()) + + +@mcp.tool(task=True) +async def slow_computation(duration: int) -> str: + return "done" +``` + +Tasks are modern-only: the capability is negotiated over `2026-07-28`, so a `mode="legacy"` client never triggers one. A tool marked `task=True` (equivalently `mode="optional"`) then just runs synchronously. A tool that sets `TaskConfig(mode="required")` has no synchronous form to fall back to, so the call fails with a missing-required-capability error instead. See [Background Tasks](/servers/tasks). + ## `import fastmcp` stopped working after I upgraded with pip This can happen when you upgrade to FastMCP 3.3 or later from FastMCP 3.2 or earlier with `pip`. The quick fix is `pip install --force-reinstall fastmcp`. See [Troubleshooting](/getting-started/installation#troubleshooting) for the clean-reinstall fallback and an explanation of why it happens. diff --git a/docs/more/settings.mdx b/docs/more/settings.mdx index 8395b8900..6ea808af7 100644 --- a/docs/more/settings.mdx +++ b/docs/more/settings.mdx @@ -23,7 +23,7 @@ You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` env |---|---|---|---| | `FASTMCP_LOG_LEVEL` | `Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]` | `INFO` | Log level for FastMCP's own logging output. Case-insensitive. | | `FASTMCP_LOG_ENABLED` | `bool` | `true` | Enable or disable FastMCP logging entirely. | -| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. | +| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Handshake-era clients can override this per-session using the MCP `logging/setLevel` request; the modern protocol has no session to hold that level, so clients on it filter by level in their own log handler instead. | | `FASTMCP_ENABLE_RICH_LOGGING` | `bool` | `true` | Use rich formatting for log output. Set to `false` for plain Python logging. | | `FASTMCP_ENABLE_RICH_TRACEBACKS` | `bool` | `true` | Use rich tracebacks for errors. | | `FASTMCP_DEPRECATION_WARNINGS` | `bool` | `true` | Show deprecation warnings. | @@ -77,7 +77,7 @@ These control how the server listens when running with an HTTP transport. | Environment Variable | Type | Default | Description | |---|---|---|---| -| `FASTMCP_ENABLE_TELEMETRY` | `bool` | `true` | Whether FastMCP's native [OpenTelemetry instrumentation](/servers/telemetry) is active. Enabled by default; FastMCP uses only the OpenTelemetry API, so span creation is a no-op with negligible overhead unless an OpenTelemetry SDK and exporter are configured. Set to `false` to turn instrumentation off entirely, in which case no FastMCP spans are created even when an SDK is configured. | +| `FASTMCP_TELEMETRY_MODE` | `Literal["native", "propagation_only", "off"]` | `native` | Controls FastMCP's native [OpenTelemetry instrumentation](/servers/telemetry). `native` emits FastMCP's MCP spans and propagates trace context; because FastMCP uses only the OpenTelemetry API, this costs almost nothing unless an SDK and exporter are configured. `propagation_only` keeps `_meta` trace propagation and still parents downstream spans from the incoming context, but emits none of FastMCP's own spans, so another instrumentation layer can own the MCP span hierarchy. `off` is a full pass-through: no spans, and no trace context extracted or attached. | ## Tasks (Docket) diff --git a/docs/python-sdk/fastmcp-exceptions.mdx b/docs/python-sdk/fastmcp-exceptions.mdx index c7dcfeb93..a6ef59df9 100644 --- a/docs/python-sdk/fastmcp-exceptions.mdx +++ b/docs/python-sdk/fastmcp-exceptions.mdx @@ -10,7 +10,7 @@ Custom exceptions for FastMCP. ## Functions -### `to_mcp_error` +### `to_mcp_error` ```python to_mcp_error(exc: Exception) -> MCPError @@ -119,3 +119,16 @@ or policy tripped. Error when authorization check fails. + +### `InsufficientScopeError` + + +Authorization failed because the token is missing required OAuth scopes. + +Unlike a bare ``AuthorizationError``, this carries the specific scopes the +caller must obtain. A component-level scope shortfall can then be signalled +as a spec-correct ``insufficient_scope`` step-up (SEP-2350 / RFC 6750 §3), +naming exactly what to re-authorize for instead of an opaque denial. The +named scopes are only the *unmet* ones, so an existing grant is accumulated +rather than replaced when the caller re-authorizes. + diff --git a/docs/python-sdk/fastmcp-utilities-authorization.mdx b/docs/python-sdk/fastmcp-utilities-authorization.mdx index f70c44283..0f64a0a4a 100644 --- a/docs/python-sdk/fastmcp-utilities-authorization.mdx +++ b/docs/python-sdk/fastmcp-utilities-authorization.mdx @@ -15,7 +15,7 @@ deny with a custom message; other exceptions are masked and treated as denial. ## Functions -### `require_scopes` +### `require_scopes` ```python require_scopes(*scopes: str) -> AuthCheck @@ -25,7 +25,52 @@ require_scopes(*scopes: str) -> AuthCheck Require all of the given OAuth scopes. -### `restrict_tag` +### `require_roles` + +```python +require_roles(*roles: str) -> AuthCheck +``` + + +Require all of the given roles, read from the token's claims. + +Roles and groups are not part of OIDC, so every identity provider puts them +somewhere different: `realm_access.roles` on Keycloak, `roles` on Microsoft +Entra, `cognito:groups` on AWS Cognito, `permissions` or a namespaced custom +claim on Auth0. `extract` receives the token's claims and returns the +caller's roles, which keeps that provider-specific knowledge at the call +site instead of guessing it here. + +```python +from fastmcp.server.auth import require_roles + +keycloak = require_roles("admin", extract=lambda c: c["realm_access"]["roles"]) +cognito = require_roles("admins", extract=lambda c: c["cognito:groups"]) +``` + +A token missing the claim entirely is denied rather than treated as an +error, so `extract` may index into the claims without guarding. An +extractor returning a bare string is treated as one role, since a provider +that stores a single role as a scalar is common. + +Unlike `require_scopes`, this check cannot signal a shortfall: OAuth has no +way to request a role, so there is no `insufficient_scope` challenge to +emit. A role denial is therefore reported as a plain `AuthorizationError`, +and it suppresses any scope shortfall alongside it — a caller blocked by +their role must not be told to go obtain a scope that would not help. +Scope shortfalls are still reported normally whenever the role check +passes. + +**Args:** +- `*roles`: Roles the caller must hold. All are required (AND logic). +- `extract`: Callable mapping the token's claims to the caller's roles. + +**Raises:** +- `ValueError`: If no roles are given, which would allow any authenticated +caller and is more likely a mistake than an intent. + + +### `restrict_tag` ```python restrict_tag(tag: str) -> AuthCheck @@ -35,14 +80,62 @@ restrict_tag(tag: str) -> AuthCheck Require scopes when the accessed component has a specific tag. -### `run_auth_checks` +### `scope_requirements` + +```python +scope_requirements(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> list[str] | None +``` + + +Scopes a check list requires but the token lacks, without running it. + +Returns ``None`` when the list contains any opaque (non-scope) check. Such a +check might deny for a reason unrelated to scopes, and evaluating it here +would run authorization logic — with whatever side effects it carries — +outside its normal place in the chain. Since its verdict is unknown, its +siblings' scopes must not be disclosed either, so the whole list is withheld. + +When every check is scope-aware, the result is their combined shortfall, +computed purely from the token and component (an empty list means the list is +already satisfied). This lets a shortfall be aggregated across authorization +layers without evaluating anything that would otherwise be skipped. + + +### `run_auth_checks_with_shortfall` + +```python +run_auth_checks_with_shortfall(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> tuple[bool, list[str]] +``` + + +Run auth checks with AND logic, classifying the denial cause. + +Returns ``(authorized, missing_scopes)``. ``missing_scopes`` names every +scope the caller must obtain to satisfy *all* scope requirements at once: +the union of the shortfalls across every scope-aware check, not just the +first one to fail. Reporting only the first would strand a caller in a +step-up loop — it obtains that scope, retries, and is denied again for the +next — so the union is what makes a single re-authorization converge. + +The challenge is withheld entirely (an empty list, which the caller surfaces +as a plain ``AuthorizationError``) unless every non-scope check passes. A +custom policy denial — a tenant check, say — must never be reported as an +``insufficient_scope`` shortfall, and must never name the scopes of a +component the caller could not otherwise reach. To guarantee that, the +opaque checks are all evaluated before any scope is disclosed; a shortfall +is only reported once they have all passed. + +An ``AuthorizationError`` raised by a check propagates unchanged. + + +### `run_auth_checks` ```python run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool ``` -Run auth checks with AND logic. +Run auth checks with AND logic, stopping at the first failure. ## Classes diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx index f4f952a2a..8f8e580bf 100644 --- a/docs/python-sdk/fastmcp-utilities-json_schema.mdx +++ b/docs/python-sdk/fastmcp-utilities-json_schema.mdx @@ -7,7 +7,7 @@ sidebarTitle: json_schema ## Functions -### `require_discriminator_property` +### `require_discriminator_property` ```python require_discriminator_property(schema: dict[str, Any]) -> dict[str, Any] @@ -24,7 +24,7 @@ model with ``union_tag_not_found``. No-op if there is no string ``propertyName``. -### `dereference_refs` +### `dereference_refs` ```python dereference_refs(schema: dict[str, Any]) -> dict[str, Any] @@ -57,7 +57,7 @@ schemas from untrusted servers. - when no longer needed -### `resolve_root_ref` +### `resolve_root_ref` ```python resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any] @@ -79,7 +79,7 @@ the referenced definition while preserving $defs for nested references. - if no resolution is needed -### `compress_schema` +### `compress_schema` ```python compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, dereference: bool = False) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-utilities-types.mdx b/docs/python-sdk/fastmcp-utilities-types.mdx index 34d543b4b..6b8ca2ce8 100644 --- a/docs/python-sdk/fastmcp-utilities-types.mdx +++ b/docs/python-sdk/fastmcp-utilities-types.mdx @@ -77,7 +77,7 @@ This is used to exclude parameters from type adapter processing when they can't The excluded parameters are removed from the function's __annotations__ dictionary. -### `replace_type` +### `replace_type` ```python replace_type(type_, type_map: dict[type, type]) @@ -145,13 +145,13 @@ Helper class for returning audio from tools. **Methods:** -#### `to_audio_content` +#### `to_audio_content` ```python to_audio_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp_types.AudioContent ``` -### `File` +### `File` Helper class for returning file data from tools. @@ -159,10 +159,10 @@ Helper class for returning file data from tools. **Methods:** -#### `to_resource_content` +#### `to_resource_content` ```python to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp_types.EmbeddedResource ``` -### `ContextSamplingFallbackProtocol` +### `ContextSamplingFallbackProtocol` diff --git a/docs/servers/auth/authentication.mdx b/docs/servers/auth/authentication.mdx index d37c57f36..a948a647c 100644 --- a/docs/servers/auth/authentication.mdx +++ b/docs/servers/auth/authentication.mdx @@ -189,11 +189,19 @@ from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier +upstream_verifier = JWTVerifier( + jwks_uri="https://login.example.com/.well-known/jwks.json", + issuer="https://login.example.com", + audience="my-app", +) + auth = MultiAuth( server=OAuthProxy( - issuer_url="https://login.example.com/...", - client_id="my-app", - client_secret="secret", + upstream_authorization_endpoint="https://login.example.com/oauth/authorize", + upstream_token_endpoint="https://login.example.com/oauth/token", + upstream_client_id="my-app", + upstream_client_secret="secret", + token_verifier=upstream_verifier, base_url="https://my-server.com", ), verifiers=[ diff --git a/docs/servers/auth/multi-auth.mdx b/docs/servers/auth/multi-auth.mdx index ba54d25ab..3675c92a6 100644 --- a/docs/servers/auth/multi-auth.mdx +++ b/docs/servers/auth/multi-auth.mdx @@ -22,11 +22,19 @@ from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier +upstream_verifier = JWTVerifier( + jwks_uri="https://login.example.com/.well-known/jwks.json", + issuer="https://login.example.com", + audience="my-app", +) + auth = MultiAuth( server=OAuthProxy( - issuer_url="https://login.example.com/...", - client_id="my-app", - client_secret="secret", + upstream_authorization_endpoint="https://login.example.com/oauth/authorize", + upstream_token_endpoint="https://login.example.com/oauth/token", + upstream_client_id="my-app", + upstream_client_secret="secret", + token_verifier=upstream_verifier, base_url="https://my-server.com", ), verifiers=[ diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index e7fb1909e..5cc0f633a 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -114,7 +114,7 @@ mcp = FastMCP(name="My Server", auth=auth) Public URL where OAuth endpoints will be accessible, **including any mount path** (e.g., `https://your-server.com/api`). - This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to specify where auth server metadata is located (typically at root level). + This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to give the server an OAuth identity that differs from where its endpoints are mounted (typically the root level). @@ -135,6 +135,8 @@ mcp = FastMCP(name="My Server", auth=auth) Issuer URL for OAuth authorization server metadata (defaults to `base_url`). + `issuer_url` is the server's OAuth identity: it is the `issuer` field of the authorization server metadata, the `iss` claim of the tokens the proxy mints, and the RFC 9207 `iss` parameter on authorization responses. `base_url` remains the location of the endpoints, so `authorization_endpoint`, `token_endpoint`, and the rest of the metadata still point at `base_url` where the routes are actually mounted. + When `issuer_url` has a path component (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`. **Default behavior (recommended for most cases):** @@ -204,9 +206,11 @@ mcp = FastMCP(name="My Server", auth=auth) - List of all possible valid scopes for the OAuth provider. These are advertised - to clients through the `/.well-known` endpoints. Defaults to `required_scopes` - from your TokenVerifier if not specified. + The complete set of scopes clients are allowed to request — the full set of + available scopes (a superset of `required_scopes`). These are advertised to + clients through the `/.well-known` endpoints and enforced at Dynamic Client + Registration. Defaults to `required_scopes` from your TokenVerifier if not + specified. @@ -585,6 +589,27 @@ auth = OAuthProxy( Check your server logs for "Client registered with redirect_uri" messages to identify what URLs your clients use. +### Application Type (Web vs. Native) + + + +During Dynamic Client Registration, a client may declare an `application_type` (per RFC 7591 and SEP-837) that governs which redirect URIs it is allowed to use. The OAuth proxy honors this field both at registration and when authorizing a redirect. + +`application_type` defaults to `"native"` because MCP clients typically run locally and register loopback callbacks. Clients that omit the field keep the permissive behavior described above. A client that explicitly registers as `"web"` is held to the stricter browser-app rules. + +Loopback covers the whole reserved range in both the address and name forms: every address in `127.0.0.0/8`, `::1`, and — per RFC 6761 — the name `localhost` along with any subdomain of it, such as `app.localhost`. The absolute (trailing-dot) spellings `localhost.` and `127.0.0.1.` are treated identically. A name that merely contains `localhost` as a label of a registrable domain, like `localhost.example.com`, is an ordinary public host and is not treated as loopback. + +| `application_type` | Allowed redirect URIs | +| ------------------ | --------------------- | +| `"native"` (default) | `https` URLs; app and private-use schemes (`vscode://callback`, `com.example.app:/callback`, `myapp://callback`, `urn:ietf:wg:oauth:2.0:oob`); and loopback `http` (`http://127.0.0.1`, any address in `127.0.0.0/8`, `http://localhost`, subdomains such as `http://app.localhost`, `http://[::1]`, any port) | +| `"web"` | `https` on a non-loopback host only | + +Web clients must register a non-loopback `https` callback — that is the restriction SEP-837 asks for, and a web client that registers no redirect URI at all is refused, since it could never complete an authorization. Native clients keep the full range of schemes their platforms use; the only new limit is that cleartext `http` must target a loopback host, per RFC 8252 §7.3. + +Both application types always reject unsafe browser schemes (`javascript:`, `data:`, `file:`, `vbscript:`). FastMCP does not otherwise filter a native client's scheme: there is no reliable way to tell an app-dispatch scheme from a network transport, since the IANA registry lists `vscode:` alongside `coap:` and `smb:`, so any such filter would reject callbacks that real MCP clients depend on. + +A redirect URI that violates the declared type is refused during registration with a `RegistrationError` (`invalid_redirect_uri`). For example, a `"web"` client that registers `http://localhost:12345/callback` is rejected, since web clients must use a non-loopback `https` callback. Configure remote, browser-based clients as `application_type="web"` and give them an `https` callback URL. + ## CIMD Support @@ -699,7 +724,7 @@ For each ID-JAG presented at the token endpoint, the proxy checks that: - the JOSE header `typ` is `oauth-id-jag+jwt`; - the `iss` claim is one of the configured `trusted_issuers`; - the signature verifies against the issuer's published keys; -- the `aud` claim identifies this authorization server; +- the `aud` claim identifies this authorization server — configure your identity provider to mint assertions whose `aud` is the `issuer` value published at `/.well-known/oauth-authorization-server`, which is your `issuer_url` when you set one and your `base_url` otherwise; - the signed `client_id` claim matches the client presenting the assertion — an assertion the IdP minted for one client cannot be redeemed by another; - the signed `resource` claim names this server — an assertion minted for a different MCP server behind the same IdP is rejected; - `exp` (and `iat`/`nbf`, when present) place the assertion within a short lifetime and its validity window; and diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index 8580c28fd..763963858 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -132,6 +132,15 @@ These patterns apply to MCP client loopback redirects. Configure the upstream OA + + The complete set of scopes clients are allowed to request — the full set of + available scopes (a superset of `required_scopes`). These are advertised to + clients through the `/.well-known` endpoints (as `scopes_supported`) and + enforced at Dynamic Client Registration: a client registering with a scope + outside this set is rejected. Defaults to `required_scopes` from your token + verifier if not specified. + + Token endpoint authentication method for the upstream OAuth server. Controls how the proxy authenticates when exchanging authorization codes and refresh tokens with the upstream provider. - `"client_secret_basic"`: Send credentials in Authorization header (most common) diff --git a/docs/servers/authorization.mdx b/docs/servers/authorization.mdx index a48d2a9e8..1ba9a3b0e 100644 --- a/docs/servers/authorization.mdx +++ b/docs/servers/authorization.mdx @@ -58,6 +58,75 @@ def read_write_operation() -> str: return "Read/write action completed" ``` +### require_roles + + + +Scopes are standardized, so `require_scopes` works the same everywhere. Roles and groups are not part of OIDC, so every identity provider puts them under a different claim. `require_roles` handles the comparison and takes an `extract` callable that tells it where to look. + +```python +from fastmcp import FastMCP +from fastmcp.server.auth import require_roles + +def keycloak_roles(claims: dict) -> list[str]: + return claims["realm_access"]["roles"] + +mcp = FastMCP("Role Server") + +@mcp.tool(auth=require_roles("admin", extract=keycloak_roles)) +def admin_operation() -> str: + """Requires the 'admin' role.""" + return "Admin action completed" + +@mcp.tool(auth=require_roles("admin", "auditor", extract=keycloak_roles)) +def audited_admin_operation() -> str: + """Requires both the 'admin' AND 'auditor' roles.""" + return "Audited admin action" +``` + +Multiple roles are required together, matching `require_scopes`. A token whose claims lack the path entirely is denied rather than raising, so the extractor can index directly. + +Keeping the claim path at the call site means any provider works, including ones with unusual shapes. Common locations: + +| Provider | Extractor | +| --- | --- | +| Keycloak | `lambda c: c["realm_access"]["roles"]` | +| Microsoft Entra | `lambda c: c["roles"]` | +| AWS Cognito | `lambda c: c["cognito:groups"]` | +| Auth0 | `lambda c: c["permissions"]` | + +Verify the claim against your own tenant before relying on it. Auth0's namespaced custom claims are configured per tenant, and Entra emits `roles` or `groups` depending on the app manifest. + + +`require_roles` cannot signal a scope shortfall, because OAuth has no way to request a role. A role denial surfaces as a plain `AuthorizationError` rather than one of the `insufficient_scope` challenges described in [Signaling Scope Shortfalls](#signaling-scope-shortfalls), and it suppresses any scope shortfall raised alongside it — a caller blocked by their role should not be told to go obtain a scope that would not help them. Combining `require_roles` with `require_scopes` is otherwise fine: whenever the role check passes, a scope shortfall is reported as usual. + + +### Checking Other Claims + +`require_roles` is a convenience for the common case. `AccessToken.claims` holds every claim from the token, so gating on anything else needs no special API — just an auth check that reads it. + +```python +from fastmcp import FastMCP +from fastmcp.server.auth import AuthCheck, AuthContext + +mcp = FastMCP("Claim Server") + +def require_tenant(tenant_id: str) -> AuthCheck: + """Require the token to come from a specific tenant.""" + def check(ctx: AuthContext) -> bool: + if ctx.token is None: + return False + return ctx.token.claims.get("tid") == tenant_id + return check + +@mcp.tool(auth=require_tenant("acme")) +def tenant_operation() -> str: + """Only callable by tokens issued for the acme tenant.""" + return "Tenant action completed" +``` + +The same caveat applies: a check like this is opaque, so it suppresses scope disclosure for its siblings. + ### restrict_tag Tag-based restrictions apply scope requirements conditionally. If a component has the specified tag, the token must have the required scopes. Components without the tag are unaffected. @@ -107,7 +176,7 @@ Any callable that accepts `AuthContext` and returns `bool` can serve as an auth ```python from fastmcp import FastMCP -from fastmcp.server.auth import AuthContext +from fastmcp.server.auth import AuthCheck, AuthContext mcp = FastMCP("Custom Auth Server") @@ -117,7 +186,7 @@ def require_premium_user(ctx: AuthContext) -> bool: return False return ctx.token.claims.get("premium", False) is True -def require_access_level(minimum_level: int): +def require_access_level(minimum_level: int) -> AuthCheck: """Factory function for level-based authorization.""" def check(ctx: AuthContext) -> bool: if ctx.token is None: @@ -168,6 +237,7 @@ Sync and async checks can be freely combined in a list — each check is handled Auth checks can raise exceptions for explicit denial with custom messages: - **`AuthorizationError`**: Propagates with its custom message, useful for explaining why access was denied +- **`InsufficientScopeError`**: A subclass of `AuthorizationError` raised by `AuthMiddleware` when the denial is a missing scope; it [names the scopes the caller needs](#signaling-scope-shortfalls) - **Other exceptions**: Masked for security (logged internally, treated as denial) ```python @@ -215,7 +285,7 @@ Component-level `auth` controls both visibility (list filtering) and access (dir ## Server-Level Authorization -For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution with explicit `AuthorizationError` responses. +For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution with explicit `AuthorizationError` responses. When the denial is specifically a missing scope, the error [names the scopes the caller needs](#signaling-scope-shortfalls). ```python from fastmcp import FastMCP @@ -296,6 +366,48 @@ def read_record(id: str) -> str: return f"Record {id}" ``` +### Signaling Scope Shortfalls + + + +A denial is more useful when it says what would fix it. When `AuthMiddleware` blocks a call because the token is missing scopes — rather than because some other policy rejected it — it raises `InsufficientScopeError`, which carries the specific scopes the caller needs in its `required_scopes` attribute. An agent that reads the error knows exactly which scopes to re-authorize for, instead of retrying blindly against an opaque refusal. + +`InsufficientScopeError` subclasses `AuthorizationError`, so existing handlers that catch `AuthorizationError` keep catching it and nothing about your error handling has to change to adopt this. + +Only the scopes the token *lacks* are named, so re-authorizing accumulates permissions rather than replacing them. A caller holding `read` that needs `read` and `write` is told to obtain `write` alone, and keeps `read` through the re-authorization. When several scope requirements fail at once, every unmet scope is reported together — a caller granted them all in one round succeeds on the retry, instead of discovering the next missing scope only after obtaining the first. + +```python +from fastmcp import FastMCP +from fastmcp.exceptions import InsufficientScopeError +from fastmcp.server.auth import require_scopes +from fastmcp.server.middleware import AuthMiddleware + +mcp = FastMCP( + "Step-Up Server", + middleware=[AuthMiddleware(auth=require_scopes("read", "write"))], +) + +@mcp.tool +def update_record(id: str) -> str: + """Requires both 'read' and 'write'.""" + return f"Updated {id}" + +# A token holding only "read" is denied with: +# InsufficientScopeError(required_scopes=["write"]) +``` + +This holds across several `AuthMiddleware` instances too, not just several checks within one. In the [tag-based configuration](#tag-based-global-authorization) each middleware contributes its own requirement, and the first to find a shortfall reports the requirements of the others alongside its own — so one re-authorization covers the whole chain rather than one layer at a time. + +A shortfall is reported only when the scope requirement is what actually caused the denial. If you [combine checks](#combining-checks) and a non-scope check rejects the request first — a tenant policy, say — the denial stays a plain `AuthorizationError` and names no scopes at all. Disclosing a scope requirement for a component the caller could not reach anyway would leak information about components they are not authorized to see. + +That rule also bounds what gets aggregated. Combining requirements only reaches as far down the chain as the request itself would have gone: it stops at the first layer holding a custom check, since whether that layer would admit the caller is unknown until it runs, and running it early would trigger authorization logic the request had not reached yet. Requirements at or beyond that point sit behind an unverified gate and are left out. + +So a custom check early in the chain makes the reported set partial, and a caller may need more than one round to satisfy everything. The reported set is complete when the layers ahead are scope-only and conservative otherwise: it may name fewer scopes than the full chain requires, but it never names scopes behind a policy that might reject the caller regardless. + + +This names the missing scopes in the error rather than emitting an HTTP `403` challenge. A per-tool denial is a JSON-RPC error carried inside a `200` response, so there is no HTTP status at that layer to attach a `WWW-Authenticate` header to. Token-level scope failures — where the token does not satisfy the server's own `required_scopes` — are a separate concern handled by the transport middleware, which does return a spec-correct `403` with an `insufficient_scope` challenge. + + ## Accessing Tokens in Tools Tools can access the current authentication token using `get_access_token()` from `fastmcp.server.dependencies`. This enables tools to make decisions based on user identity or permissions beyond simple authorization checks. @@ -376,9 +488,15 @@ from fastmcp.server.auth import ( AuthContext, # Context with .token, .component AuthCheck, # Type alias: sync or async Callable[[AuthContext], bool] require_scopes, # Built-in: requires specific scopes + require_roles, # Built-in: requires roles read from token claims restrict_tag, # Built-in: tag-based scope requirements run_auth_checks, # Utility: run checks with AND logic ) +from fastmcp.exceptions import ( + AuthorizationError, # Denial with a custom message + InsufficientScopeError, # Subclass of AuthorizationError; has .required_scopes +) + from fastmcp.server.middleware import AuthMiddleware ``` diff --git a/docs/servers/completions.mdx b/docs/servers/completions.mdx index e7727b0e9..d59a18488 100644 --- a/docs/servers/completions.mdx +++ b/docs/servers/completions.mdx @@ -19,7 +19,7 @@ A server has a single completion handler, registered with the `@mcp.completion` ```python from fastmcp import FastMCP -from mcp_types import PromptReference +from mcp.types import PromptReference mcp = FastMCP("Completion Server") @@ -56,7 +56,7 @@ The same handler answers completion for resource template parameters. A `Resourc ```python from fastmcp import FastMCP -from mcp_types import ResourceTemplateReference +from mcp.types import ResourceTemplateReference mcp = FastMCP("Completion Server") @@ -84,7 +84,7 @@ Completions often depend on values the user has already entered. A repository su ```python from fastmcp import FastMCP -from mcp_types import ResourceTemplateReference +from mcp.types import ResourceTemplateReference mcp = FastMCP("Completion Server") @@ -122,7 +122,7 @@ The MCP protocol caps a single response at 100 values. When more candidates exis ```python from fastmcp import FastMCP -from mcp_types import Completion, PromptReference +from mcp.types import Completion, PromptReference mcp = FastMCP("Completion Server") @@ -158,7 +158,7 @@ A completion handler may be sync or async, and it can reach the active request t ```python from fastmcp import FastMCP from fastmcp.server.dependencies import get_context -from mcp_types import PromptReference +from mcp.types import PromptReference mcp = FastMCP("Completion Server") diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index 48fcb74d0..01a5372d3 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -21,7 +21,6 @@ The `Context` object provides a clean interface to access MCP features within yo - **Progress Reporting**: Update the client on the progress of long-running operations - **Resource Access**: List and read data from resources registered with the server - **Prompt Access**: List and retrieve prompts registered with the server -- **LLM Sampling**: Request the client's LLM to generate text based on provided messages - **User Elicitation**: Request structured input from users during tool execution - **Request State**: Pass values and non-serializable resources between middleware and handlers within a request (for state that persists across requests, see [Session State](/servers/sessions)) - **Session Visibility**: [Control which components are visible](/servers/visibility#per-session-visibility) to the current session @@ -152,18 +151,9 @@ if result.action == "accept": See [User Elicitation](/servers/elicitation) for detailed examples and supported response types. -### LLM Sampling - - - -Request the client's LLM to generate text based on provided messages, useful for leveraging AI capabilities within your tools. - -```python -response = await ctx.sample("Analyze this data", temperature=0.7) -``` - -See [LLM Sampling](/servers/sampling) for comprehensive usage and advanced techniques. +### Sampling and Roots +Neither capability has a `Context` method. Both used to *push* a request into a live client connection, which the modern MCP protocol has no channel to carry, so a tool now asks for them by returning the request and reading the answer on the next round — the same [guard pattern](/servers/elicitation#sampling-and-roots) elicitation uses on modern connections. That route is the natural one for roots; for generation, [call an LLM directly from your server](/servers/sampling). ### Progress Reporting @@ -281,7 +271,7 @@ Tools can customize which components are visible to their current session using FastMCP automatically sends list change notifications when components (such as tools, resources, or prompts) are added, removed, enabled, or disabled. In rare cases where you need to manually trigger these notifications, you can use the context's notification methods: ```python -import mcp_types +import mcp.types as mcp_types @mcp.tool async def custom_tool_management(ctx: Context) -> str: diff --git a/docs/servers/elicitation.mdx b/docs/servers/elicitation.mdx index 9b7b0c164..eacde0b60 100644 --- a/docs/servers/elicitation.mdx +++ b/docs/servers/elicitation.mdx @@ -27,7 +27,7 @@ Elicitation reaches the user two different ways, depending on the protocol era t - **On handshake-era connections (≤ 2025-11-25)**, a running tool calls [`ctx.elicit()`](#requesting-input-on-handshake-connections). The tool pauses mid-execution, the server sends a request over the session back-channel, and the tool resumes with the answer. This is the original elicitation API and the rest of this page's first half covers it in full. - **On the modern protocol (2026-07-28)**, that back-channel is gone — server-initiated requests were removed from the wire (SEP-2577), so a tool cannot issue a request mid-execution and block on the answer. Instead a tool asks for input by *returning* a description of what it needs; each round completes normally and the client issues a new call with the answer attached. This is the [guard pattern](#elicitation-on-the-modern-protocol), covered in the second half. -The era gate is strict: `ctx.elicit()` only works on handshake connections, and the guard pattern only works on modern ones. A tool that returns a guard result on a handshake connection — or calls `ctx.elicit()` on a modern one — raises a clear era error rather than failing obscurely. A server that serves both eras may need both paths; branch on `ctx.protocol_version` to pick the right one. `fastmcp.Client` drives whichever the connection negotiated automatically. +The era gate is strict: `ctx.elicit()` only works on handshake connections, and the guard pattern only works on modern ones. A tool that returns a guard result on a handshake connection — or calls `ctx.elicit()` on a modern one — raises a clear era error rather than failing obscurely. A server that serves both eras may need both paths; branch on `ctx.request_context.protocol_version` to pick the right one. `fastmcp.Client` drives whichever the connection negotiated automatically. ## Requesting input on handshake connections @@ -187,9 +187,9 @@ async def confirm_purchase(ctx: Context) -> str: These arguments only apply when FastMCP is adding the wrapper. For structured responses (`BaseModel`, dataclass, `TypedDict`), set the metadata on the individual fields via `Field(title=..., description=...)` — passing `response_title` or `response_description` alongside a model type raises `TypeError`. -### Empty Responses +### Confirmations -Passing `None` as the response type creates an empty-object schema and returns an accepted result with `data == {}`. This form is deprecated because some clients render empty forms poorly; prefer an explicit response type such as `bool` for confirmations. +`response_type` is required. When all you want is a yes/no answer, ask for a `bool` rather than an empty schema — an empty schema gives the client nothing to render, and some clients show an empty, non-functional form. ```python @mcp.tool @@ -412,7 +412,7 @@ The following tool books a flight across three rounds: it asks for a destination ```python from fastmcp import FastMCP, Context -from mcp_types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams +from mcp.types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams mcp = FastMCP("Booking Server") @@ -539,13 +539,66 @@ connection negotiated '2025-11-25'. Use ctx.elicit() for server-initiated input on handshake-era connections. ``` -If you need to support both eras, branch on `ctx.protocol_version`: return an `InputRequiredResult` on modern connections and fall back to [`ctx.elicit()`](#requesting-input-on-handshake-connections) on handshake-era ones. +If you need to support both eras, branch on `ctx.request_context.protocol_version`: return an `InputRequiredResult` on modern connections and fall back to [`ctx.elicit()`](#requesting-input-on-handshake-connections) on handshake-era ones. + +### Prompts and resources + +`InputRequiredResult` is a **result type**, not a tools feature: any request can resolve to one. Prompts, resources, and resource templates ask for input exactly the way tools do — return an `InputRequiredResult`, read `ctx.input_responses` on the next round, and the client re-issues the same `prompts/get` or `resources/read` with the answer attached. + +This prompt gathers the context it needs before rendering: + +```python +from fastmcp import FastMCP, Context +from mcp.types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams + +mcp = FastMCP("Reporting Server") + +ask_for_quarter = InputRequiredResult( + result_type="input_required", + input_requests={ + "quarter": ElicitRequest( + method="elicitation/create", + params=ElicitRequestFormParams( + message="Which quarter should the summary cover?", + requested_schema={ + "type": "object", + "properties": {"quarter": {"type": "string"}}, + "required": ["quarter"], + }, + ), + ) + }, +) + + +@mcp.prompt +async def summarize(ctx: Context) -> str | InputRequiredResult: + responses = ctx.input_responses + if responses is None: + return ask_for_quarter + quarter = responses["quarter"].content["quarter"] + return f"Summarize the {quarter} results." +``` + +Resources and resource templates work the same way, with the URI standing in for the tool name: + +```python +@mcp.resource("report://summary") +async def report(ctx: Context) -> str | InputRequiredResult: + responses = ctx.input_responses + if responses is None: + return ask_for_quarter + quarter = responses["quarter"].content["quarter"] + return f"Revenue report for {quarter}" +``` + +The same protocol requirement applies: returning an `InputRequiredResult` from a prompt or resource needs a 2026-07-28 connection, and FastMCP names the era mismatch if one arrives on an older one. Client-side, `read_resource` and `get_prompt` drive the loop the way `call_tool` does, so a configured elicitation handler answers all three without extra wiring. ### Sampling and roots -Elicitation is the most common request to carry this way, and **roots** requests work identically — the `input_requests` map holds them the same way, and each answer comes back in `ctx.input_responses` under its key (an `ElicitResult` or `ListRootsResult`). See [Client Roots](/clients/roots) for what a roots request contains. `fastmcp.Client` answers both from the handlers you already configured, so a guard tool that mixes them needs no extra client wiring. +Elicitation is the most common request to carry this way, and the map carries the others just as well. A `ListRootsRequest` or a `CreateMessageRequest` sits in `input_requests` exactly as an `ElicitRequest` does, and its answer arrives in `ctx.input_responses` under the same key as a `ListRootsResult` or a `CreateMessageResult`. One map can mix all three, and `fastmcp.Client` answers each from the handlers it already has — `elicitation_handler=`, `roots=`, and `sampling_handler=` — so a tool that asks for a mixture needs no extra client wiring. [Client Roots](/clients/roots) covers what a roots request contains. -The map can structurally hold a **sampling** request too (its answer would be a `CreateMessageResult`), but SEP-2577 deprecated server-initiated sampling on the modern protocol, so reach for a direct server-side LLM call instead of routing generation through a guard round. See [Sampling](/servers/sampling). +Roots and sampling differ in how well they suit the round trip. A server asks for roots once and then has what it needs, so the extra round buys the whole answer. Generation rarely works out that way, because every round is a full request-response cycle and a tool that generates in a loop pays that cost each time — [call an LLM directly from your server](/servers/sampling) unless the point is specifically to use the caller's model. ### Middleware diff --git a/docs/servers/icons.mdx b/docs/servers/icons.mdx index 6e2e9381a..065c28471 100644 --- a/docs/servers/icons.mdx +++ b/docs/servers/icons.mdx @@ -15,7 +15,7 @@ Icons provide visual representations for your MCP servers and components, helpin Icons use the standard MCP Icon type from the MCP protocol specification. Each icon specifies a source URL or data URI, and optionally includes MIME type, size, and theme information. ```python -from mcp_types import Icon +from mcp.types import Icon icon = Icon( src="https://example.com/icon.png", @@ -37,7 +37,7 @@ Add icons and a website URL to your server for display in client applications. M ```python from fastmcp import FastMCP -from mcp_types import Icon +from mcp.types import Icon mcp = FastMCP( name="WeatherService", @@ -66,7 +66,7 @@ Icons can be added to individual tools, resources, resource templates, and promp ### Tool Icons ```python -from mcp_types import Icon +from mcp.types import Icon @mcp.tool( icons=[Icon(src="https://example.com/calculator-icon.png")] @@ -121,7 +121,7 @@ Supply two icons with complementary `theme` values and the client picks the one ```python from fastmcp import FastMCP -from mcp_types import Icon +from mcp.types import Icon mcp = FastMCP( name="WeatherService", @@ -135,7 +135,7 @@ mcp = FastMCP( The same field works on tools, resources, resource templates, and prompts: ```python -from mcp_types import Icon +from mcp.types import Icon @mcp.tool( icons=[ @@ -155,7 +155,7 @@ Omitting `theme` means the icon is assumed suitable for any theme. That's the ri For small icons or when you want to embed the icon directly without external dependencies, use data URIs. This approach eliminates the need for hosting and ensures the icon is always available. ```python -from mcp_types import Icon +from mcp.types import Icon from fastmcp.utilities.types import Image # SVG icon as data URI @@ -175,7 +175,7 @@ def my_tool() -> str: FastMCP provides the `Image` utility class to convert local image files into data URIs. ```python -from mcp_types import Icon +from mcp.types import Icon from fastmcp.utilities.types import Image # Generate a data URI from a local image file diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx index 4e824dec0..ae7172522 100644 --- a/docs/servers/prompts.mdx +++ b/docs/servers/prompts.mdx @@ -438,6 +438,10 @@ Notifications are only sent when these operations occur within an active MCP req Clients can handle these notifications using a [message handler](/clients/notifications) to automatically refresh their prompt lists or update their interfaces. +## Requesting Input + +A prompt can ask the client for information before it renders. On an MCP 2026-07-28 connection, return an `InputRequiredResult` describing what you need; the client answers and re-issues the `prompts/get`, and your function runs again with the answer on `ctx.input_responses`. See [Elicitation](/servers/elicitation#prompts-and-resources) for the full pattern. + ## Server Behavior ### Duplicate Prompts diff --git a/docs/servers/providers/proxy.mdx b/docs/servers/providers/proxy.mdx index 5ec71d487..2be18cefd 100644 --- a/docs/servers/providers/proxy.mdx +++ b/docs/servers/providers/proxy.mdx @@ -172,6 +172,8 @@ backend = ProxyClient( ### Tool Results Are Relayed, Not Inspected + + A proxy passes a backend's tool results through untouched, including results that don't match the output schema the backend advertised. Deciding whether a server honored its own contract belongs to the client consuming the result, and that client validates for itself. This matters when a backend's declared schema is subtly wrong — an enum missing a variant it actually returns, say. A proxy that enforced the schema would replace the backend's working response with an error of its own, and the client would never see what the backend actually said. diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 7f45c7280..e0daf6e76 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -783,6 +783,10 @@ def get_data_by_id(id: str) -> dict: When `mask_error_details=True`, only error messages from `ResourceError` will include details, other exceptions will be converted to a generic message. +## Requesting Input + +A resource or resource template can ask the client for information before it produces content. On an MCP 2026-07-28 connection, return an `InputRequiredResult` describing what you need; the client answers and re-issues the `resources/read`, and your function runs again with the answer on `ctx.input_responses`. See [Elicitation](/servers/elicitation#prompts-and-resources) for the full pattern. + ## Server Behavior ### Duplicate Resources diff --git a/docs/servers/sampling.mdx b/docs/servers/sampling.mdx index 4dd2886d9..e3448bcbe 100644 --- a/docs/servers/sampling.mdx +++ b/docs/servers/sampling.mdx @@ -1,7 +1,7 @@ --- title: Sampling sidebarTitle: Sampling -description: Request LLM text generation from the client or a configured provider through the MCP context. +description: Generate text from a FastMCP server — by calling an LLM directly, or by asking the client to sample. icon: robot --- @@ -10,582 +10,102 @@ import { VersionBadge } from "/snippets/version-badge.mdx" -**Sampling is deprecated and will be removed in a future FastMCP release.** +**`ctx.sample()` and `ctx.sample_step()` were removed in FastMCP 4.** The modern MCP protocol gives a server no channel to push a request to its client, so there is nothing left for those methods to do. -`ctx.sample()` and `ctx.sample_step()` rely on server-initiated `createMessage` -requests, which MCP removed as of the 2026-07-28 protocol (SEP-2577). They work -only on session-based (handshake-era) connections; on a 2026-07-28 connection -they raise a clear error rather than reaching the client. - -**Migration:** call an LLM directly from your server using your own API key and -provider SDK instead of borrowing the client's model. There is no drop-in -replacement on modern connections — this architectural shift is the intended -answer. +To build a server that uses sampling, stay on [FastMCP 3.x](/v3/servers/sampling). On FastMCP 4, generate by [calling an LLM directly](#calling-an-llm-directly), or [ask the caller's model](#asking-the-callers-model) when borrowing their model is the point. - -This page covers `ctx.sample()`, which requests generation over the handshake-era back-channel. The modern protocol's guard mechanism can structurally carry a sampling request in its `input_requests` map, but SEP-2577 deprecated server-initiated sampling as a pattern rather than only the `ctx.sample()` spelling of it, so that is not a supported migration path. Use the guard mechanism for [elicitation](/servers/elicitation#elicitation-on-the-modern-protocol) and roots, and call an LLM directly from your server for generation. - +A tool that needs text generated calls a model to get it, and in FastMCP 4 that call is ordinary Python: your server holds an API key, creates a provider client, and awaits a completion inside the tool. No protocol is involved, so the tool behaves the same for every client — including the many that never implemented sampling at all. -LLM sampling allows your MCP tools to request text generation from an LLM during execution. This enables tools to leverage AI capabilities for analysis, generation, reasoning, and more—without the client needing to orchestrate multiple calls. +The alternative is to ask the caller. Sampling borrows *the caller's* model — their provider, their credentials, their bill — by returning a request for a completion that the client fulfils and hands back. Every ask costs a full round trip, so it earns its keep when using the caller's model is the point, and rarely otherwise. -By default, sampling requests are routed to the client's LLM. You can also configure a fallback handler to use a specific provider (like OpenAI) when the client doesn't support sampling, or to always use your own LLM regardless of client capabilities. +## Calling an LLM directly -## Overview - -The simplest use of sampling is passing a prompt string to `ctx.sample()`. The method sends the prompt to the LLM, waits for the complete response, and returns a `SamplingResult`. You can access the generated text through the `.text` attribute. - -```python -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -@mcp.tool -async def summarize(content: str, ctx: Context) -> str: - """Generate a summary of the provided content.""" - result = await ctx.sample(f"Please summarize this:\n\n{content}") - return result.text or "" -``` - -The `SamplingResult` also provides `.result` (identical to `.text` for plain text responses) and `.history` containing the full message exchange—useful if you need to continue the conversation or debug the interaction. - -### System Prompts - -System prompts let you establish the LLM's role and behavioral guidelines before it processes your request. This is useful for controlling tone, enforcing constraints, or providing context that shouldn't clutter the user-facing prompt. - -````python -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -@mcp.tool -async def generate_code(concept: str, ctx: Context) -> str: - """Generate a Python code example for a concept.""" - result = await ctx.sample( - messages=f"Write a Python example demonstrating '{concept}'.", - system_prompt=( - "You are an expert Python programmer. " - "Provide concise, working code without explanations." - ), - temperature=0.7, - max_tokens=300 - ) - return f"```python\n{result.text}\n```" -```` - -The `temperature` parameter controls randomness—higher values (up to 1.0) produce more varied outputs, while lower values make responses more deterministic. The `max_tokens` parameter limits response length. - -### Model Preferences - -Model preferences let you hint at which LLM the client should use for a request. You can pass a single model name or a list of preferences in priority order. These are hints rather than requirements—the actual model used depends on what the client has available. - -```python -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -@mcp.tool -async def technical_analysis(data: str, ctx: Context) -> str: - """Analyze data using a reasoning-focused model.""" - result = await ctx.sample( - messages=f"Analyze this data:\n\n{data}", - model_preferences=["claude-opus-4-5", "gpt-5-2"], - temperature=0.2, - ) - return result.text or "" -``` - -Use model preferences when different tasks benefit from different model characteristics. Creative writing might prefer faster models with higher temperature, while complex analysis might benefit from larger reasoning-focused models. - -### Multi-Turn Conversations - -For requests that need conversational context, construct a list of `SamplingMessage` objects representing the conversation history. Each message has a `role` ("user" or "assistant") and `content` (a `TextContent` object). - -```python -from mcp_types import SamplingMessage, TextContent -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -@mcp.tool -async def contextual_analysis(query: str, data: str, ctx: Context) -> str: - """Analyze data with conversational context.""" - messages = [ - SamplingMessage( - role="user", - content=TextContent(type="text", text=f"Here's my data: {data}"), - ), - SamplingMessage( - role="assistant", - content=TextContent(type="text", text="I see the data. What would you like to know?"), - ), - SamplingMessage( - role="user", - content=TextContent(type="text", text=query), - ), - ] - result = await ctx.sample(messages=messages) - return result.text or "" -``` - -The LLM receives the full conversation thread and responds with awareness of the preceding context. - -### Fallback Handlers - -Client support for sampling is optional—some clients may not implement it. To ensure your tools work regardless of client capabilities, configure a `sampling_handler` that sends requests directly to an LLM provider. - -FastMCP provides built-in handlers for [OpenAI and Anthropic APIs](/clients/sampling#built-in-handlers). These handlers support the full sampling API including tools, automatically converting your Python functions to each provider's format. - - -Install handlers with `pip install 'fastmcp[openai]'` or `pip install 'fastmcp[anthropic]'`. - +Hold a provider API key in your server's environment, create the client once at module scope so connections are reused across calls, and generate inside the tool. You choose the model, control the prompt, see the token usage, and can test the tool with no client attached. ```python +import anthropic from fastmcp import FastMCP -from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler -server = FastMCP( - name="My Server", - sampling_handler=OpenAISamplingHandler(default_model="gpt-4o-mini"), - sampling_handler_behavior="fallback", -) -``` +mcp = FastMCP("Summarizer") +llm = anthropic.AsyncAnthropic() -The `sampling_handler_behavior` parameter controls when the handler is used: - -- **`"fallback"`** (default): Use the handler only when the client doesn't support sampling. This lets capable clients use their own LLM while ensuring your tools still work with clients that lack sampling support. -- **`"always"`**: Always use the handler, bypassing the client entirely. Use this when you need guaranteed control over which LLM processes requests—for cost control, compliance requirements, or when specific model characteristics are essential. - -## Structured Output - - - -When you need validated, typed data instead of free-form text, use the `result_type` parameter. FastMCP ensures the LLM returns data matching your type, handling validation and retries automatically. - -The `result_type` parameter accepts Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`. When you specify a result type, FastMCP automatically creates a `final_response` tool that the LLM calls to provide its response. If validation fails, the error is sent back to the LLM for retry. - -```python -from pydantic import BaseModel -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -class SentimentResult(BaseModel): - sentiment: str - confidence: float - reasoning: str @mcp.tool -async def analyze_sentiment(text: str, ctx: Context) -> SentimentResult: - """Analyze text sentiment with structured output.""" - result = await ctx.sample( - messages=f"Analyze the sentiment of: {text}", - result_type=SentimentResult, +async def summarize(text: str) -> str: + """Summarize a document in two sentences.""" + response = await llm.messages.create( + model="claude-sonnet-4-5", + max_tokens=512, + system="Summarize the user's text in exactly two sentences.", + messages=[{"role": "user", "content": text}], ) - return result.result # A validated SentimentResult object + return response.content[0].text ``` -When you call this tool, the LLM returns a structured response that FastMCP validates against your Pydantic model. You access the validated object through `result.result`, while `result.text` contains the JSON representation. +Any provider SDK works the same way — swap the client and the call, and the tool signature is unchanged. Because generation is ordinary application code, the concerns around it are ordinary too: retries, timeouts, caching, and cost accounting go wherever you want them rather than being negotiated across a protocol boundary. A tool that chains several generations pays nothing extra for the second and third, where asking the caller would pay a full round trip for each. -### Structured Output with Tools +## Asking the caller's model -Combine structured output with tools for agentic workflows that return validated data. The LLM uses your tools to gather information, then returns a response matching your type. +A tool asks for a completion by returning an `InputRequiredResult` whose `input_requests` map holds a `CreateMessageRequest` under a key you choose. That result completes the round normally. The client runs the completion, then re-issues the same `call_tool` with the answer attached, and your tool reads it from `ctx.input_responses` under the same key — a `CreateMessageResult`. Because the tool runs from the top on every round, the presence of `ctx.input_responses` is what tells the two rounds apart: `None` on the first call, populated on the continuation. + +`fastmcp.Client` drives that loop for you and answers from the [`sampling_handler`](/clients/sampling) it already has, so a client written for a handshake-era server needs no extra wiring to satisfy a modern tool that asks this way. ```python -from pydantic import BaseModel -from fastmcp import FastMCP, Context +from fastmcp import Context, FastMCP +from mcp.types import ( + CreateMessageRequest, + CreateMessageRequestParams, + CreateMessageResult, + InputRequiredResult, + SamplingMessage, + TextContent, +) -mcp = FastMCP() +mcp = FastMCP("Research") -def search(query: str) -> str: - """Search the web for information.""" - return f"Results for: {query}" - -def fetch_url(url: str) -> str: - """Fetch content from a URL.""" - return f"Content from: {url}" - -class ResearchResult(BaseModel): - summary: str - sources: list[str] - confidence: float @mcp.tool -async def research(topic: str, ctx: Context) -> ResearchResult: - """Research a topic and return structured findings.""" - result = await ctx.sample( - messages=f"Research: {topic}", - tools=[search, fetch_url], - result_type=ResearchResult, - ) - return result.result -``` - - -Structured output with automatic validation only applies to `sample()`. With `sample_step()`, you must manage structured output yourself. - - -## Tool Use - - - -Sampling with tools enables agentic workflows where the LLM can call functions to gather information before responding. This implements [SEP-1577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577), allowing the LLM to autonomously orchestrate multi-step operations. - -Pass Python functions to the `tools` parameter, and FastMCP handles the execution loop automatically—calling tools, returning results to the LLM, and continuing until the LLM provides a final response. - -### Defining Tools - -Define regular Python functions with type hints and docstrings. FastMCP extracts the function's name, docstring, and parameter types to create tool schemas that the LLM can understand. - -```python -from fastmcp import FastMCP, Context - -def search(query: str) -> str: - """Search the web for information.""" - return f"Results for: {query}" - -def get_time() -> str: - """Get the current time.""" - from datetime import datetime - return datetime.now().strftime("%H:%M:%S") - -mcp = FastMCP() - -@mcp.tool -async def research(question: str, ctx: Context) -> str: - """Answer questions using available tools.""" - result = await ctx.sample( - messages=question, - tools=[search, get_time], - ) - return result.text or "" -``` - -The LLM sees each function's signature and docstring, using this information to decide when and how to call them. Tool errors are caught and sent back to the LLM, allowing it to recover gracefully. An internal safety limit prevents infinite loops. - -### Custom Tool Definitions - -For custom names or descriptions, use `SamplingTool.from_function()`: - -```python -from fastmcp.server.sampling import SamplingTool - -tool = SamplingTool.from_function( - my_func, - name="custom_name", - description="Custom description" -) - -result = await ctx.sample(messages="...", tools=[tool]) -``` - -### Error Handling - -By default, when a sampling tool raises an exception, the error message (including details) is sent back to the LLM so it can attempt recovery. To prevent sensitive information from leaking to the LLM, use the `mask_error_details` parameter: - -```python -result = await ctx.sample( - messages=question, - tools=[search], - mask_error_details=True, # Generic error messages only -) -``` - -When `mask_error_details=True`, tool errors become generic messages like `"Error executing tool 'search'"` instead of exposing stack traces or internal details. - -To intentionally provide specific error messages to the LLM regardless of masking, raise `ToolError`: - -```python -from fastmcp.exceptions import ToolError - -def search(query: str) -> str: - """Search for information.""" - if not query.strip(): - raise ToolError("Search query cannot be empty") - return f"Results for: {query}" -``` - -`ToolError` messages always pass through to the LLM, making it the escape hatch for errors you want the LLM to see and handle. - -### Concurrent Tool Execution - -By default, tools execute sequentially — one at a time, in order. When your tools are independent (no shared state between them), you can execute them in parallel with `tool_concurrency`: - -```python -result = await ctx.sample( - messages="Research these three topics", - tools=[search, fetch_url], - tool_concurrency=0, # Unlimited parallel execution -) -``` - -The `tool_concurrency` parameter controls how many tools run at once: - -- **`None`** (default): Sequential execution -- **`0`**: Unlimited parallel execution -- **`N > 0`**: Execute at most N tools concurrently - -For tools that must not run concurrently (file writes, shared state mutations, etc.), mark them as `sequential` when creating the `SamplingTool`: - -```python -from fastmcp.server.sampling import SamplingTool - -db_writer = SamplingTool.from_function( - write_to_db, - sequential=True, # Forces all tools in the batch to run sequentially -) - -result = await ctx.sample( - messages="Process this data", - tools=[search, db_writer], - tool_concurrency=0, # Would be parallel, but db_writer forces sequential -) -``` - - -When any tool in a batch has `sequential=True`, the entire batch executes sequentially regardless of `tool_concurrency`. This is a conservative guarantee — if one tool needs ordering, all tools in that batch respect it. - - -### Client Requirements - - -Sampling with tools requires the client to advertise the `sampling.tools` capability. FastMCP clients do this automatically. For external clients that don't support tool-enabled sampling, configure a fallback handler with `sampling_handler_behavior="always"`. - - -## Advanced Control - - - -While `sample()` handles the tool execution loop automatically, some scenarios require fine-grained control over each step. The `sample_step()` method makes a single LLM call and returns a `SampleStep` containing the response and updated history. - -Unlike `sample()`, `sample_step()` is stateless—it doesn't remember previous calls. You control the conversation by passing the full message history each time. The returned `step.history` includes all messages up through the current response, making it easy to continue the loop. - -Use `sample_step()` when you need to: - -- Inspect tool calls before they execute -- Implement custom termination conditions -- Add logging, metrics, or checkpointing between steps -- Build custom agentic loops with domain-specific logic - -### Basic Loop - -By default, `sample_step()` executes any tool calls and includes the results in the history. Call it in a loop, passing the updated history each time, until a stop condition is met. - -```python -from mcp_types import SamplingMessage -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -def search(query: str) -> str: - return f"Results for: {query}" - -def get_time() -> str: - return "12:00 PM" - -@mcp.tool -async def controlled_agent(question: str, ctx: Context) -> str: - """Agent with manual loop control.""" - messages: list[str | SamplingMessage] = [question] - - while True: - step = await ctx.sample_step( - messages=messages, - tools=[search, get_time], - ) - - if step.is_tool_use: - # Tools already executed (execute_tools=True by default) - for call in step.tool_calls: - print(f"Called tool: {call.name}") - - if not step.is_tool_use: - return step.text or "" - - messages = step.history -``` - -### SampleStep Properties - -Each `SampleStep` provides information about what the LLM returned: - -| Property | Description | -|----------|-------------| -| `step.is_tool_use` | True if the LLM requested tool calls | -| `step.tool_calls` | List of tool calls requested (if any) | -| `step.text` | The text content (if any) | -| `step.history` | All messages exchanged so far | - -The contents of `step.history` depend on `execute_tools`: -- **`execute_tools=True`** (default): Includes tool results, ready for the next iteration -- **`execute_tools=False`**: Includes the assistant's tool request, but you add results yourself - -### Manual Tool Execution - -Set `execute_tools=False` to handle tool execution yourself. When disabled, `step.history` contains the user message and the assistant's response with tool calls—but no tool results. You execute the tools and append the results as a user message. - -```python -from mcp_types import SamplingMessage, ToolResultContent, TextContent -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -@mcp.tool -async def research(question: str, ctx: Context) -> str: - """Research with manual tool handling.""" - - def search(query: str) -> str: - return f"Results for: {query}" - - def get_time() -> str: - return "12:00 PM" - - tools = {"search": search, "get_time": get_time} - messages: list[SamplingMessage] = [question] - - while True: - step = await ctx.sample_step( - messages=messages, - tools=list(tools.values()), - execute_tools=False, - ) - - if not step.is_tool_use: - return step.text or "" - - # Execute tools and collect results - tool_results = [] - for call in step.tool_calls: - fn = tools[call.name] - result = fn(**call.input) - tool_results.append( - ToolResultContent( - type="tool_result", - tool_use_id=call.id, - content=[TextContent(type="text", text=result)], +async def ask_the_caller(question: str, ctx: Context) -> str | InputRequiredResult: + """Put a question to the caller's model and report what it answered.""" + responses = ctx.input_responses + if responses is None: + return InputRequiredResult( + result_type="input_required", + input_requests={ + "answer": CreateMessageRequest( + method="sampling/createMessage", + params=CreateMessageRequestParams( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=question), + ) + ], + max_tokens=100, + ), ) - ) + }, + ) - messages = list(step.history) - messages.append(SamplingMessage(role="user", content=tool_results)) + answer = responses["answer"] + if isinstance(answer, CreateMessageResult) and isinstance( + answer.content, TextContent + ): + return answer.content.text + return "The client returned no completion." ``` -To report an error to the LLM, set `is_error=True` on the tool result: +Returning an `InputRequiredResult` needs a `2026-07-28` connection, and FastMCP names the era mismatch if an older client reaches the tool; the conformance suite exercises this route on that version. The map can carry several requests at once and mix kinds — a sampling request beside an elicitation or a roots request — with each answer coming back under its own key. [Elicitation](/servers/elicitation#sampling-and-roots) covers the mechanics of the pattern in full, including how to carry state across rounds. -```python -tool_result = ToolResultContent( - type="tool_result", - tool_use_id=call.id, - content=[TextContent(type="text", text="Permission denied")], - is_error=True, -) -``` +## The removed methods -## Method Reference +`Context` has no `sample()` and no `sample_step()`; touching either raises `AttributeError` on every protocol era, rather than failing at runtime only against modern clients. `FastMCP()` accepts neither `sampling_handler=` nor `sampling_handler_behavior=`, and naming one raises a `TypeError` that points at the migration. - - - Request text generation from the LLM, running to completion automatically. +The reason is the distinction MCP draws between telling and asking. A notification is fire-and-forget: the server emits it and moves on, and it travels down the response stream the caller already opened, so nothing has to be held open on the server's behalf. That is why [logging](/servers/logging) is untouched by any of this — `ctx.info()` and its siblings reach the client mid-call on every era. Sampling is the other kind. `sampling/createMessage` goes out and the caller must answer before the tool can continue, which needs a live, addressable connection the server can reach into, and the `2026-07-28` revision removed server-initiated requests ([SEP-2577](https://modelcontextprotocol.io/community/sep-guidelines)) precisely because a stateless protocol has no such thing. - - - The prompt to send. Can be a simple string or a list of messages for multi-turn conversations. - +What the protocol removed is the pushing, not the asking, so the capability survives in the shape described above. Keeping `ctx.sample()` alongside it would mean shipping a method whose outcome against a default client — one that negotiates the modern era — is a runtime failure. - - Instructions that establish the LLM's role and behavior. - - - - Controls randomness (0.0 = deterministic, 1.0 = creative). - - - - Maximum tokens to generate. - - - - Hints for which model the client should use. - - - - Functions the LLM can call during sampling. - - - - A type for validated structured output. Supports Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`. - - - - If True, mask detailed error messages from tool execution. When None (default), uses the global `settings.mask_error_details` value. Tools can raise `ToolError` to bypass masking and provide specific error messages to the LLM. - - - - Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. If any tool has `sequential=True`, all tools execute sequentially regardless. - - - - - - - - `.text`: The raw text response (or JSON for structured output) - - `.result`: The typed result—same as `.text` for plain text, or a validated Pydantic object for structured output - - `.history`: All messages exchanged during sampling - - - - - - - - Make a single LLM sampling call. Use this for fine-grained control over the sampling loop. - - - - The prompt or conversation history. - - - - Instructions that establish the LLM's role and behavior. - - - - Controls randomness (0.0 = deterministic, 1.0 = creative). - - - - Maximum tokens to generate. - - - - Functions the LLM can call during sampling. - - - - Controls tool usage: `"auto"`, `"required"`, or `"none"`. - - - - If True, execute tool calls and append results to history. If False, return immediately with tool calls available for manual execution. - - - - If True, mask detailed error messages from tool execution. - - - - Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. - - - - - - - `.response`: The raw LLM response - - `.history`: Messages including input, assistant response, and tool results - - `.is_tool_use`: True if the LLM requested tool execution - - `.tool_calls`: List of tool calls (if any) - - `.text`: The text content (if any) - - - - + +Servers on FastMCP 3 still have `ctx.sample()` and `ctx.sample_step()`, documented in the [FastMCP 3 sampling guide](/v3/servers/sampling). Nothing changes for them until they upgrade. + diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index 9f6dbe8fa..ee778f887 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -195,7 +195,7 @@ These parameters tune how the server processes requests and communicates with cl - Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"` + Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Handshake-era clients can override this per-session using the MCP `logging/setLevel` request; the modern protocol has no session to hold that level, so clients on it filter by level in their own log handler instead. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"` @@ -211,19 +211,9 @@ These parameters tune how the server processes requests and communicates with cl -### Handlers and Storage - -These parameters provide custom handlers for MCP capabilities and persistent storage for session state. +### Storage - - Custom handler for MCP sampling requests (server-initiated LLM calls). See [Sampling](/servers/sampling) for details - - - - When `"fallback"`, the sampling handler is used only when no tool-specific handler exists. When `"always"`, this handler is used for all sampling requests - - Persistent key-value store for session state that survives across requests. Defaults to an in-memory store. Provide a custom implementation for persistence across server restarts diff --git a/docs/servers/tasks.mdx b/docs/servers/tasks.mdx index e3b26ad5d..2b07bd440 100644 --- a/docs/servers/tasks.mdx +++ b/docs/servers/tasks.mdx @@ -225,7 +225,7 @@ A tool can ask the client a question partway through — the same [guard pattern ```python from fastmcp import Context, FastMCP from fastmcp_tasks import TasksExtension -import mcp_types +import mcp.types as mcp_types mcp = FastMCP("MyServer") mcp.add_extension(TasksExtension()) diff --git a/docs/servers/telemetry.mdx b/docs/servers/telemetry.mdx index 7e7356f5e..9573f07bd 100644 --- a/docs/servers/telemetry.mdx +++ b/docs/servers/telemetry.mdx @@ -6,6 +6,8 @@ icon: chart-line tag: NEW --- +import { VersionBadge } from "/snippets/version-badge.mdx" + FastMCP includes native OpenTelemetry instrumentation for observability. Traces are automatically generated for tool, prompt, resource, resource template, and task management operations, providing visibility into server behavior, request handling, and provider delegation chains. ## How It Works @@ -19,9 +21,21 @@ FastMCP uses the OpenTelemetry API for instrumentation. This means: Because FastMCP only depends on the OpenTelemetry API, span creation is a no-op until you configure an SDK and exporter — so being on by default costs nothing until you opt into collection. -### Turning Telemetry Off +### Telemetry Modes -To disable FastMCP's instrumentation entirely, set `FASTMCP_ENABLE_TELEMETRY=false` (or `fastmcp.settings.enable_telemetry = False`). When disabled, FastMCP creates no spans even if an SDK is configured. + + +`FASTMCP_TELEMETRY_MODE` (or `fastmcp.settings.telemetry_mode`) controls how much of the instrumentation is active: + +| Mode | FastMCP spans | Trace context | +|---|---|---| +| `native` (default) | Emitted | Propagated | +| `propagation_only` | Suppressed | Propagated | +| `off` | Suppressed | Untouched | + +Use `off` to disable FastMCP's instrumentation entirely. No spans are created even if an SDK is configured, and FastMCP leaves the surrounding OpenTelemetry context exactly as it found it. + +Use `propagation_only` when another instrumentation layer already owns the MCP span hierarchy — see [Interoperability](#interoperability) below. ## Enabling Telemetry @@ -144,6 +158,37 @@ trace.set_tracer_provider(provider) The name check must happen before `ParentBased` delegates. If the name-based sampler is nested inside `ParentBased`, it is not consulted for child spans whose parent was already sampled. +## Interoperability + + + +FastMCP assumes it owns the MCP span hierarchy. When something else already owns it — an MCP-aware OpenTelemetry instrumentation library, or a service mesh that understands the protocol — FastMCP's spans duplicate what that layer already emits, and the same request shows up twice in your traces. + +Setting `propagation_only` resolves the duplication in FastMCP's favor of the other layer: + +```bash +export FASTMCP_TELEMETRY_MODE=propagation_only +``` + +The distinction from `off` matters here. Both emit no FastMCP spans, but `off` is fully transparent, while `propagation_only` still extracts the trace context arriving in `_meta` and attaches it for the duration of the request. Spans created downstream — by your tool handlers, or by the instrumentation layer that owns the hierarchy — are parented to the calling trace rather than starting a new one. Outbound requests still carry `traceparent` and `tracestate` in `_meta`. + +### Suppressing spans for a single block + +Library authors embedding FastMCP inside their own instrumented stack often want to own the hierarchy for one specific operation rather than process-wide. `suppress_fastmcp_telemetry()` applies `propagation_only` semantics to a block: + +```python +from fastmcp import Client +from fastmcp.telemetry import suppress_fastmcp_telemetry + +async def search(client: Client, query: str): + with suppress_fastmcp_telemetry(): + return await client.call_tool("search", {"query": query}) +``` + +This is narrower than OpenTelemetry's global instrumentation suppression: only FastMCP's spans are skipped, so nested instrumentation for HTTP clients, databases, and everything else keeps emitting normally. + +The context manager has no effect when `telemetry_mode` is already `off`. A request to skip FastMCP's spans cannot re-enable the context propagation that `off` deliberately omits. + ## Programmatic Configuration For more control, configure the SDK in your Python code before importing FastMCP: @@ -238,7 +283,7 @@ Custom spans are most useful around work that is expensive or hard to debug: - External calls such as databases, vector stores, HTTP APIs, or queue operations - Multi-step tool logic where one stage dominates latency - Prompt or resource generation that fans out to other systems -- Sampling calls made from inside a tool via `ctx.sample(...)` +- LLM calls a tool makes to a model provider Avoid wrapping every small helper function or simple in-memory transformation. That usually adds noise without making traces easier to interpret. @@ -286,9 +331,9 @@ async def docs_resource(slug: str) -> str: return await load_doc(slug) ``` -### Sampling calls inside tools +### LLM calls inside tools -If your tool uses `ctx.sample(...)`, keep the LLM work nested under the tool span so traces show both application logic and model latency together. +A tool that [calls an LLM directly](/servers/sampling) should keep the model work nested under the tool span, so traces show application logic and model latency together. For providers with their own OTEL integrations, prefer enabling that instrumentation rather than manually creating a span around every model call. For example, if you use Google GenAI, `logfire.instrument_google_genai()` will emit child spans with token and request metadata under the active FastMCP tool span. diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 2e5fe4d85..527b16b9b 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -722,8 +722,8 @@ Schema generation works for most common types including basic types, collections For complete control over tool responses, return a `ToolResult` object. This gives you explicit control over all aspects of the tool's output: traditional content, structured data, and metadata. ```python -from fastmcp.tools.tool import ToolResult -from mcp_types import TextContent +from fastmcp.tools import ToolResult +from mcp.types import TextContent @mcp.tool def advanced_tool() -> ToolResult: @@ -788,7 +788,7 @@ When you need custom serialization (like YAML, Markdown tables, or specialized f ```python import yaml from fastmcp import FastMCP -from fastmcp.tools.tool import ToolResult +from fastmcp.tools import ToolResult mcp = FastMCP("MyServer") @@ -944,7 +944,7 @@ Annotations serve several purposes in client applications: You can add annotations to a tool using the `annotations` parameter in the `@mcp.tool` decorator. FastMCP accepts either a plain dict or `ToolAnnotations`; the examples below use `ToolAnnotations` for consistency and stronger editor/type support. ```python -from mcp_types import ToolAnnotations +from mcp.types import ToolAnnotations @mcp.tool( annotations=ToolAnnotations( @@ -983,7 +983,7 @@ Mark a tool as read-only when it retrieves data, performs calculations, or check ```python from fastmcp import FastMCP -from mcp_types import ToolAnnotations +from mcp.types import ToolAnnotations mcp = FastMCP("Data Server") @@ -1056,22 +1056,14 @@ mcp = FastMCP(name="ContextDemo") async def process_data(data_uri: str, ctx: Context) -> dict: """Process data from a resource with progress reporting.""" await ctx.info(f"Processing data from {data_uri}") - - # Read a resource - resource = await ctx.read_resource(data_uri) - data = resource[0].content if resource else "" - - # Report progress + + result = await ctx.read_resource(data_uri) + data = result.contents[0].content if result.contents else "" await ctx.report_progress(progress=50, total=100) - - # Example request to the client's LLM for help - summary = await ctx.sample(f"Summarize this in 10 words: {data[:200]}") - + + summary = str(data)[:200] await ctx.report_progress(progress=100, total=100) - return { - "length": len(data), - "summary": summary.text - } + return {"length": len(data), "summary": summary} ``` The Context object provides access to: @@ -1079,7 +1071,6 @@ The Context object provides access to: - **Logging**: `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()` - **Progress Reporting**: `ctx.report_progress(progress, total)` - **Resource Access**: `ctx.read_resource(uri)` -- **LLM Sampling**: `ctx.sample(...)` - **Request Information**: `ctx.request_id`, `ctx.client_id` For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context). diff --git a/docs/servers/transforms/code-mode.mdx b/docs/servers/transforms/code-mode.mdx index c7ef55bf0..5b04fcab5 100644 --- a/docs/servers/transforms/code-mode.mdx +++ b/docs/servers/transforms/code-mode.mdx @@ -250,7 +250,7 @@ Here's a minimal example: from fastmcp.experimental.transforms.code_mode import CodeMode from fastmcp.experimental.transforms.code_mode import GetToolCatalog, GetSchemas from fastmcp.server.context import Context -from fastmcp.tools.tool import Tool +from fastmcp.tools import Tool def list_all_tools(get_catalog: GetToolCatalog) -> Tool: async def list_tools(ctx: Context) -> str: diff --git a/docs/servers/transforms/tool-search.mdx b/docs/servers/transforms/tool-search.mdx index 204004f5c..c3f44a3cf 100644 --- a/docs/servers/transforms/tool-search.mdx +++ b/docs/servers/transforms/tool-search.mdx @@ -153,6 +153,8 @@ Tools discovered through search can also be called directly via `client.call_too Search results respect the full authorization pipeline. Tools filtered by middleware, visibility transforms, or component-level auth checks won't appear in search results. +App-only tools are excluded too. A [MCP app](/apps/overview) can declare backend tools that only its UI may call, and normally the host keeps those from the model. A search result is tool output rather than an advertised listing, so no host filtering applies to it — the exclusion happens here instead. The `call_tool` proxy enforces the same boundary, since it executes a name the model supplies. + The search tool queries `list_tools()` through the complete pipeline at search time, so the same filtering that controls what a client sees in the listing also controls what they can discover through search. ```python diff --git a/docs/servers/transforms/transforms.mdx b/docs/servers/transforms/transforms.mdx index 4347b2f18..fc8f0ceda 100644 --- a/docs/servers/transforms/transforms.mdx +++ b/docs/servers/transforms/transforms.mdx @@ -118,7 +118,7 @@ Create custom transforms by subclassing `Transform` and overriding the methods y ```python from collections.abc import Sequence from fastmcp.server.transforms import Transform, GetToolNext -from fastmcp.tools.tool import Tool +from fastmcp.tools import Tool class TagFilter(Transform): """Filter tools to only those with specific tags.""" diff --git a/docs/updates.mdx b/docs/updates.mdx index 0faf12da9..bb66069e9 100644 --- a/docs/updates.mdx +++ b/docs/updates.mdx @@ -5,6 +5,38 @@ icon: "sparkles" tag: NEW --- + + +FastMCP 4 rebuilds the framework on the MCP Python SDK v2, and this beta is the first release to run on the SDK's stable 2.0. The engine underneath changed completely, but FastMCP absorbs nearly all of it — most FastMCP 3 servers run untouched. + +🌐 **Every protocol era** — one server answers both the sessionless `2026-07-28` protocol and the older session-based handshake, negotiated per connection. + +💾 **State without a session** — `UserSession` and `SessionId` give tools durable state on a protocol that deliberately has none, keyed per user when the request is authenticated. + +⏳ **Background tasks** — the `io.modelcontextprotocol/tasks` extension in the new `fastmcp-tasks` package, on the same Docket engine FastMCP 3 used. + +🧩 **Server extensions** — `add_extension()` turns capability-negotiated protocol features into a supported plugin surface. + +🔐 **Enterprise auth** — server-side identity assertion (SEP-990), `require_roles`, scope step-up challenges, and DCR `application_type`. + +⚠️ **Breaking** — server-initiated sampling and roots are removed from the server API, and the 3.x-era compatibility shims are gone. See the [upgrade guide](/getting-started/upgrading/from-fastmcp-3). + + + + + +A maintenance release for the 3.x line. A single unrecognized JWKS key — Ed25519, which Rauthy and Ory Hydra publish by default — no longer poisons the entire key cache, alongside fixes for Azure scope fallback, OpenAPI `deepObject` query serialization, schema compression, and transformed tool `required` ordering. + + + str: - """Summarize the given text.""" - console.print(f"[bold cyan]SERVER[/] Summarizing text ({len(text)} chars)...") - - result = await ctx.sample( - messages=f"Summarize this text in 1-2 sentences:\n\n{text}", - system_prompt="You are a concise summarizer.", - max_tokens=150, - ) - - console.print("[bold cyan]SERVER[/] Summary complete") - return result.text or "" - - -@mcp.tool -async def translate(text: str, target_language: str, ctx: Context) -> str: - """Translate text to the target language.""" - console.print(f"[bold cyan]SERVER[/] Translating to {target_language}...") - - result = await ctx.sample( - messages=f"Translate to {target_language}:\n\n{text}", - system_prompt=f"You are a translator. Output only the {target_language} translation.", - max_tokens=500, - ) - - console.print("[bold cyan]SERVER[/] Translation complete") - return result.text or "" - - -async def main(): - console.print( - Panel.fit( - "[bold]Server-Side Fallback Handler Demo[/]\n\n" - "This server has a built-in Anthropic handler that activates\n" - "when clients don't provide their own sampling support.", - subtitle="server_fallback.py", - ) - ) - console.print() - console.print("[bold yellow]Starting HTTP server on http://localhost:8000[/]") - console.print("Connect with an MCP client or press Ctrl+C to stop") - console.print() - - await mcp.run_http_async(host="localhost", port=8000) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/sampling/structured_output.py b/examples/sampling/structured_output.py deleted file mode 100644 index fa7afe7ad..000000000 --- a/examples/sampling/structured_output.py +++ /dev/null @@ -1,110 +0,0 @@ -# /// script -# dependencies = ["anthropic", "fastmcp", "rich"] -# /// -""" -Structured Output Sampling - -Demonstrates using `result_type` to get validated Pydantic models from an LLM. -The server exposes a sentiment analysis tool that returns structured data. - -Run: - uv run examples/sampling/structured_output.py -""" - -import asyncio - -from pydantic import BaseModel -from rich.console import Console -from rich.panel import Panel -from rich.table import Table - -from fastmcp import Client, Context, FastMCP -from fastmcp.client.sampling import SamplingMessage, SamplingParams -from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler - -console = Console() - - -class LoggingAnthropicHandler(AnthropicSamplingHandler): - async def __call__( - self, messages: list[SamplingMessage], params: SamplingParams, context - ): # type: ignore[override] - console.print(" [bold blue]SAMPLING[/] Calling Claude API...") - result = await super().__call__(messages, params, context) - console.print(" [bold blue]SAMPLING[/] Response received") - return result - - -# Define a structured output model -class SentimentAnalysis(BaseModel): - sentiment: str # "positive", "negative", or "neutral" - confidence: float # 0.0 to 1.0 - keywords: list[str] # Keywords that influenced the analysis - explanation: str # Brief explanation of the analysis - - -# Create the MCP server -mcp = FastMCP("Sentiment Analyzer") - - -@mcp.tool -async def analyze_sentiment(text: str, ctx: Context) -> dict: - """Analyze the sentiment of the given text.""" - console.print(" [bold cyan]SERVER[/] Analyzing sentiment...") - - result = await ctx.sample( - messages=f"Analyze the sentiment of this text:\n\n{text}", - system_prompt="You are a sentiment analysis expert. Analyze text carefully.", - result_type=SentimentAnalysis, - ) - - console.print(" [bold cyan]SERVER[/] Analysis complete") - return result.result.model_dump() # type: ignore[attr-defined] - - -async def main(): - console.print( - Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="structured_output.py") - ) - console.print() - - handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5") - - async with Client(mcp, sampling_handler=handler) as client: - texts = [ - "I absolutely love this product! It exceeded all my expectations.", - "The service was okay, nothing special but got the job done.", - "This is the worst experience I've ever had. Never again.", - ] - - for text in texts: - console.print(f"[bold green]CLIENT[/] Analyzing: [italic]{text[:50]}...[/]") - console.print() - - result = await client.call_tool("analyze_sentiment", {"text": text}) - data = result.data - - # Display results in a table - table = Table(show_header=False, box=None, padding=(0, 2)) - table.add_column(style="bold") - table.add_column() - - sentiment_color = { - "positive": "green", - "negative": "red", - "neutral": "yellow", - }.get( - data["sentiment"], - "white", # type: ignore[union-attr] - ) - table.add_row("Sentiment", f"[{sentiment_color}]{data['sentiment']}[/]") # type: ignore[index] - table.add_row("Confidence", f"{data['confidence']:.0%}") # type: ignore[index] - table.add_row("Keywords", ", ".join(data["keywords"])) # type: ignore[index] - table.add_row("Explanation", data["explanation"]) # type: ignore[index] - - console.print(Panel(table, border_style=sentiment_color)) - console.print() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/sampling/text.py b/examples/sampling/text.py deleted file mode 100644 index 6d354e7c2..000000000 --- a/examples/sampling/text.py +++ /dev/null @@ -1,78 +0,0 @@ -# /// script -# dependencies = ["anthropic", "fastmcp", "rich"] -# /// -""" -Simple Text Sampling - -Demonstrates the basic MCP sampling flow where a server tool requests -an LLM completion from the client. - -Run: - uv run examples/sampling/text.py -""" - -import asyncio - -from rich.console import Console -from rich.panel import Panel - -from fastmcp import Client, Context, FastMCP -from fastmcp.client.sampling import SamplingMessage, SamplingParams -from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler - -console = Console() - - -# Create a wrapper handler that logs when the LLM is called -class LoggingAnthropicHandler(AnthropicSamplingHandler): - async def __call__( - self, messages: list[SamplingMessage], params: SamplingParams, context - ): # type: ignore[override] - console.print(" [bold blue]SAMPLING[/] Calling Claude API...") - result = await super().__call__(messages, params, context) - console.print(" [bold blue]SAMPLING[/] Response received") - return result - - -# Create the MCP server -mcp = FastMCP("Haiku Generator") - - -@mcp.tool -async def write_haiku(topic: str, ctx: Context) -> str: - """Write a haiku about any topic.""" - console.print( - f" [bold cyan]SERVER[/] Tool 'write_haiku' called with topic: {topic}" - ) - - result = await ctx.sample( - messages=f"Write a haiku about: {topic}", - system_prompt="You are a poet. Write only the haiku, nothing else.", - max_tokens=100, - ) - - console.print(" [bold cyan]SERVER[/] Returning haiku to client") - return result.text or "" - - -async def main(): - console.print(Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="text.py")) - console.print() - - # Create the sampling handler - handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5") - - # Connect client to server with the sampling handler - async with Client(mcp, sampling_handler=handler) as client: - console.print("[bold green]CLIENT[/] Calling tool 'write_haiku'...") - console.print() - - result = await client.call_tool("write_haiku", {"topic": "Python programming"}) - - console.print() - console.print("[bold green]CLIENT[/] Received result:") - console.print(Panel(result.data, title="Haiku", border_style="green")) # type: ignore[arg-type] - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/sampling/tool_use.py b/examples/sampling/tool_use.py deleted file mode 100644 index e7869a16c..000000000 --- a/examples/sampling/tool_use.py +++ /dev/null @@ -1,125 +0,0 @@ -# /// script -# dependencies = ["anthropic", "fastmcp", "rich"] -# /// -""" -Sampling with Tools - -Demonstrates giving an LLM tools to use during sampling. The LLM can call -helper functions to gather information before responding. - -Run: - uv run examples/sampling/tool_use.py -""" - -import asyncio -import random -from datetime import datetime - -from pydantic import BaseModel, Field -from rich.console import Console -from rich.panel import Panel - -from fastmcp import Client, Context, FastMCP -from fastmcp.client.sampling import SamplingMessage, SamplingParams -from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler - -console = Console() - - -class LoggingAnthropicHandler(AnthropicSamplingHandler): - async def __call__( - self, messages: list[SamplingMessage], params: SamplingParams, context - ): # type: ignore[override] - console.print(" [bold blue]SAMPLING[/] Calling Claude API...") - result = await super().__call__(messages, params, context) - console.print(" [bold blue]SAMPLING[/] Response received") - return result - - -# Define tools available to the LLM during sampling -def add(a: float, b: float) -> str: - """Add two numbers together.""" - result = a + b - console.print(f" [bold magenta]TOOL[/] add({a}, {b}) = {result}") - return str(result) - - -def multiply(a: float, b: float) -> str: - """Multiply two numbers together.""" - result = a * b - console.print(f" [bold magenta]TOOL[/] multiply({a}, {b}) = {result}") - return str(result) - - -def get_current_time() -> str: - """Get the current date and time.""" - console.print(" [bold magenta]TOOL[/] get_current_time()") - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - -def roll_dice(sides: int = 6) -> str: - """Roll a die with the specified number of sides.""" - result = random.randint(1, sides) - console.print(f" [bold magenta]TOOL[/] roll_dice({sides}) = {result}") - return str(result) - - -# Structured output for the response -class AssistantResponse(BaseModel): - answer: str = Field(description="The answer to the user's question") - tools_used: list[str] = Field(description="List of tools that were used") - reasoning: str = Field( - description="Brief explanation of how the answer was determined" - ) - - -# Create the MCP server -mcp = FastMCP("Smart Assistant") - - -@mcp.tool -async def ask_assistant(question: str, ctx: Context) -> dict: - """Ask the assistant a question. It can use tools to help answer.""" - console.print(" [bold cyan]SERVER[/] Processing question...") - - result = await ctx.sample( - messages=question, - system_prompt="You are a helpful assistant with access to tools. Use them when needed to answer questions accurately.", - tools=[add, multiply, get_current_time, roll_dice], - result_type=AssistantResponse, - ) - - console.print(" [bold cyan]SERVER[/] Response ready") - return result.result.model_dump() # type: ignore[attr-defined] - - -async def main(): - console.print(Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="tool_use.py")) - console.print() - - handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5") - - async with Client(mcp, sampling_handler=handler) as client: - questions = [ - "What is 15 times 7, plus 23?", - "Roll a 20-sided dice for me", - "What time is it right now?", - ] - - for question in questions: - console.print(f"[bold green]CLIENT[/] Question: {question}") - console.print() - - result = await client.call_tool("ask_assistant", {"question": question}) - data = result.data - - console.print(f"[bold green]CLIENT[/] Answer: {data['answer']}") # type: ignore[index] - console.print( - f" Tools used: {', '.join(data['tools_used']) or 'none'}" - ) # type: ignore[index] - console.print(f" Reasoning: {data['reasoning']}") # type: ignore[index] - console.print() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/skills/download_skills.py b/examples/skills/download_skills.py index 69b8d0373..c6e571c57 100644 --- a/examples/skills/download_skills.py +++ b/examples/skills/download_skills.py @@ -1,7 +1,7 @@ """Example: Downloading skills from an MCP server. This example shows how to use the skills client utilities to discover -and download skills from any MCP server that exposes them via SkillsProvider. +and download skills from any MCP server that exposes them via a skills provider. Run this script: uv run python examples/skills/download_skills.py diff --git a/examples/testing_demo/uv.lock b/examples/testing_demo/uv.lock index 966d76b33..a9cb8f163 100644 --- a/examples/testing_demo/uv.lock +++ b/examples/testing_demo/uv.lock @@ -1,6 +1,12 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] [options] exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. @@ -9,7 +15,13 @@ exclude-newer-span = "P1W" [options.exclude-newer-package] mcp-types = false prefab-ui = false +truststore = false +fastmcp-slim = false +fastmcp = false mcp = false +httpcore2 = false +fastmcp-remote = false +httpx2 = false [[package]] name = "aiofile" @@ -627,7 +639,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.2" +version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -645,9 +657,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] [[package]] @@ -1262,8 +1274,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ diff --git a/fastmcp_slim/README.md b/fastmcp_slim/README.md index 9afc7f4f7..4a813d96b 100644 --- a/fastmcp_slim/README.md +++ b/fastmcp_slim/README.md @@ -100,9 +100,10 @@ uv pip install fastmcp For full installation instructions, including verification and upgrading, see the [**Installation Guide**](https://gofastmcp.com/getting-started/installation). **Upgrading?** We have guides for: -- [Upgrading from FastMCP v2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) -- [Upgrading from the MCP Python SDK](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk) -- [Upgrading from the low-level SDK](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk) +- [Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3) +- [Upgrading from FastMCP 2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) +- [Upgrading from MCP SDK v1](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2) +- [Upgrading from the low-level SDK v1](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2) ## 📚 Documentation diff --git a/fastmcp_slim/fastmcp/apps/app.py b/fastmcp_slim/fastmcp/apps/app.py index 33e606118..8a4dc64d0 100644 --- a/fastmcp_slim/fastmcp/apps/app.py +++ b/fastmcp_slim/fastmcp/apps/app.py @@ -54,16 +54,20 @@ F = TypeVar("F", bound=Callable[..., Any]) def _make_resolver(app_name: str | None = None) -> Any: - """Create a CallTool resolver that prefixes tool names with a hash. + """Create a CallTool resolver that addresses peer tools by identity. - Structurally identical to the old ``___`` resolver — ``app_name`` is - the FastMCPApp's name, known at serialization time from the tool's - ``meta["fastmcp"]["app"]`` tag. The only change is the wire format: - ``_`` instead of ``___``. + ``app_name`` is the FastMCPApp's name, known at serialization time from + the tool's ``meta["fastmcp"]["app"]`` tag. Serialization happens deep + inside whatever composition the server has, so nothing here can know + what these tools will be *called* by the time the payload reaches a + host. References therefore start out identity-addressed, as + ``_``. - The dispatcher recognizes the hashed form and routes it via - ``get_tool_by_hash`` which walks the provider tree recursively — - same pattern as ``get_app_tool``. + Each FastMCP server rewrites those references on the way out to the + name it lists that tool under, so what a renderer finally receives is + an ordinary tool name (see ``server.providers.prefab_payload``). A + reference no server could resolve keeps this form, which the dispatcher + still routes via ``get_tool_by_hash``. """ from fastmcp.server.providers.addressing import ( hashed_backend_name, @@ -227,14 +231,17 @@ class FastMCPApp(Provider): raise ValueError(f"Cannot determine tool name for {fn!r}") from fastmcp.apps.config import AppConfig, app_config_to_meta_dict - from fastmcp.server.providers.addressing import hash_tool + from fastmcp.server.providers.addressing import ( + TOOL_HASH_META_KEY, + hash_tool, + ) app_config = AppConfig(visibility=visibility) meta: dict[str, Any] = { "ui": app_config_to_meta_dict(app_config), "fastmcp": { "app": self.name, - "_tool_hash": hash_tool(self.name, resolved_name), + TOOL_HASH_META_KEY: hash_tool(self.name, resolved_name), }, } @@ -318,7 +325,10 @@ class FastMCPApp(Provider): def _register(fn: F, tool_name: str | None) -> F: from fastmcp.apps.config import AppConfig, app_config_to_meta_dict - from fastmcp.server.providers.addressing import hash_tool + from fastmcp.server.providers.addressing import ( + TOOL_HASH_META_KEY, + hash_tool, + ) from fastmcp.server.providers.local_provider.decorators.tools import ( PREFAB_RENDERER_URI, ) @@ -334,7 +344,7 @@ class FastMCPApp(Provider): "ui": app_config_to_meta_dict(app_config), "fastmcp": { "app": self.name, - "_tool_hash": hash_tool(self.name, resolved), + TOOL_HASH_META_KEY: hash_tool(self.name, resolved), }, } @@ -373,12 +383,15 @@ class FastMCPApp(Provider): if not isinstance(tool, Tool): tool = Tool._ensure_tool(tool) - from fastmcp.server.providers.addressing import hash_tool + from fastmcp.server.providers.addressing import ( + TOOL_HASH_META_KEY, + hash_tool, + ) meta = dict(tool.meta) if tool.meta else {} fm = meta.setdefault("fastmcp", {}) fm["app"] = self.name - fm["_tool_hash"] = hash_tool(self.name, tool.name) + fm[TOOL_HASH_META_KEY] = hash_tool(self.name, tool.name) ui = meta.setdefault("ui", {}) if "visibility" not in ui: ui["visibility"] = ["app"] diff --git a/fastmcp_slim/fastmcp/apps/config.py b/fastmcp_slim/fastmcp/apps/config.py index 3d89aa382..1686d8626 100644 --- a/fastmcp_slim/fastmcp/apps/config.py +++ b/fastmcp_slim/fastmcp/apps/config.py @@ -11,6 +11,7 @@ from typing import Any, Literal from pydantic import BaseModel, Field +from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.mime import UI_MIME_TYPE as UI_MIME_TYPE from fastmcp.utilities.mime import resolve_ui_mime_type as resolve_ui_mime_type @@ -182,3 +183,31 @@ def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]: if isinstance(app, AppConfig): return app.model_dump(by_alias=True, exclude_none=True) return app + + +def is_model_visible(component: FastMCPComponent) -> bool: + """Whether a component may be shown to, or invoked by, the model. + + Visibility is a declaration, and the MCP Apps spec puts the filtering on + the host — so ``tools/list`` carries app-only tools and the host keeps + them from the model. That division only works where a host stands between + the server and the model. + + It does not hold for surfaces a server drives itself. A search result or + a code-mode catalog reaches the model as ordinary tool output, and a + call-tool proxy invokes on a name the model supplies; nothing downstream + can filter either. Those surfaces have to apply the declaration here. + + A component with no ``visibility`` is visible: the field marks the + exception, and the spec's default is both audiences. + """ + meta = component.meta + if not meta: + return True + ui_meta = meta.get("ui") + if not isinstance(ui_meta, dict): + return True + visibility = ui_meta.get("visibility") + if not isinstance(visibility, list): + return True + return "model" in visibility diff --git a/fastmcp_slim/fastmcp/cli/discovery.py b/fastmcp_slim/fastmcp/cli/discovery.py index 5acd42d61..ff3c616ff 100644 --- a/fastmcp_slim/fastmcp/cli/discovery.py +++ b/fastmcp_slim/fastmcp/cli/discovery.py @@ -120,7 +120,7 @@ def _parse_mcp_servers( def _parse_mcp_config(path: Path, source: str) -> list[DiscoveredServer]: """Parse an mcpServers-style JSON file into discovered servers.""" try: - text = path.read_text() + text = path.read_text(encoding="utf-8") except OSError as exc: logger.debug("Could not read %s: %s", path, exc) return [] @@ -158,7 +158,7 @@ def _scan_claude_code(start_dir: Path) -> list[DiscoveredServer]: """Scan ``~/.claude.json`` for global and project-scoped MCP servers.""" path = Path.home() / ".claude.json" try: - text = path.read_text() + text = path.read_text(encoding="utf-8") except OSError: return [] @@ -269,7 +269,7 @@ def _scan_goose() -> list[DiscoveredServer]: path = config_dir / "config.yaml" try: - text = path.read_text() + text = path.read_text(encoding="utf-8") except OSError: return [] diff --git a/fastmcp_slim/fastmcp/client/auth/client_credentials.py b/fastmcp_slim/fastmcp/client/auth/client_credentials.py index b6e8a6588..6cdcf6f1a 100644 --- a/fastmcp_slim/fastmcp/client/auth/client_credentials.py +++ b/fastmcp_slim/fastmcp/client/auth/client_credentials.py @@ -245,7 +245,7 @@ class ClientCredentialsOAuthProvider(_SDKClientCredentialsOAuthProvider): client_id=self._client_id, client_secret=self._client_secret, token_endpoint_auth_method=self._token_endpoint_auth_method, - scopes=self._scopes, + scope=self._scopes, ) self._bound = True @@ -371,7 +371,7 @@ class PrivateKeyJWTOAuthProvider(_SDKPrivateKeyJWTOAuthProvider): ), client_id=self._client_id, assertion_provider=self._assertion_provider, - scopes=self._scopes, + scope=self._scopes, ) self._bound = True diff --git a/fastmcp_slim/fastmcp/client/auth/oauth.py b/fastmcp_slim/fastmcp/client/auth/oauth.py index a73ea14aa..442e6ad33 100644 --- a/fastmcp_slim/fastmcp/client/auth/oauth.py +++ b/fastmcp_slim/fastmcp/client/auth/oauth.py @@ -344,7 +344,6 @@ class OAuth(OAuthClientProvider): storage=self.token_storage_adapter, redirect_handler=self.redirect_handler, callback_handler=self.callback_handler, - timeout=self._callback_timeout, client_metadata_url=self._client_metadata_url, ) diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py index 0bf9b9b54..c9b9aa1eb 100644 --- a/fastmcp_slim/fastmcp/client/client.py +++ b/fastmcp_slim/fastmcp/client/client.py @@ -232,6 +232,22 @@ class ClientSessionState: initialize_result: mcp_types.InitializeResult | None = None +def _connection_failure(exception: BaseException) -> BaseException: + """Present a dead session the same way wherever it is noticed. + + A failed session surfaces from two places: `_connect`, when the connection + never comes up, and `_await_with_session_monitoring`, when the session task + dies while a request is in flight. Which one wins is a matter of timing, so + both report the failure identically — otherwise the same dead backend + reaches callers as either a `RuntimeError` naming the connection or the raw + transport error, depending on the race. Types callers reasonably branch on + are passed through untouched. + """ + if isinstance(exception, httpx2.HTTPStatusError | MCPError): + return exception + return RuntimeError(f"Client failed to connect: {exception}") + + @dataclass class CallToolResult: """Parsed result from a tool call.""" @@ -530,6 +546,14 @@ class Client( "sampling_callback": None, "list_roots_callback": None, "logging_callback": create_log_callback(log_handler), + # Log delivery is opt-in per request on the modern protocol: the + # session stamps this level into each request's `_meta`, and a + # server sends nothing without it. FastMCP's contract is that a + # client receives everything unless it narrows the level itself, so + # request the most permissive level and let the server's own + # `client_log_level` (and legacy `set_logging_level`) do the + # filtering. Inert on the handshake eras, which have no such opt-in. + "log_level": "debug", "message_handler": effective_message_handler, "read_timeout_seconds": read_timeout_seconds, "client_info": client_info, @@ -982,18 +1006,23 @@ class Client( raise - if self._session_state.session_task.done(): - exception = self._session_state.session_task.exception() + session_task = self._session_state.session_task + if not session_task.done() and self._session_state.session is None: + # `_session_runner` sets `ready_event` from its `finally`, + # so a failed connect can wake the wait above before the + # task is marked done. No session means the connect failed, + # so let the task settle and report the failure here rather + # than letting the raw transport error escape on the next + # request. + await asyncio.wait([session_task], timeout=3) + + if session_task.done(): + exception = session_task.exception() if exception is None: raise RuntimeError( "Session task completed without exception but connection failed" ) - # Preserve specific exception types that clients may want to handle - if isinstance(exception, httpx2.HTTPStatusError | MCPError): - raise exception - raise RuntimeError( - f"Client failed to connect: {exception}" - ) from exception + raise _connection_failure(exception) from exception self._session_state.nesting_counter += 1 @@ -1351,7 +1380,22 @@ class Client( ) async def set_logging_level(self, level: mcp_types.LoggingLevel) -> None: - """Send a logging/setLevel request.""" + """Send a logging/setLevel request. + + Handshake-era servers only. `logging/setLevel` asks the server to + remember a level for the rest of the session, and the 2026-07-28 + protocol has no session to remember it in — the method is absent from + that era's registry. Log *notifications* are unaffected: they ride the + request's own stream, so a server's `ctx.info()` still reaches you. + Filter by level on the receiving side instead, in your `log_handler`. + """ + if self.protocol_version in MODERN_PROTOCOL_VERSIONS: + raise RuntimeError( + "logging/setLevel is not available on MCP 2026-07-28 " + "connections; the method requires per-session server state that " + "the modern protocol does not have. Filter incoming log " + "messages by level in your log_handler instead." + ) # Deprecated upstream in SDK v2 but deliberately kept per compat directive; # removed with the multi-round-trip follow-up. await self._await_with_session_monitoring( diff --git a/fastmcp_slim/fastmcp/client/messages.py b/fastmcp_slim/fastmcp/client/messages.py index 7183a8e67..8dfbfd964 100644 --- a/fastmcp_slim/fastmcp/client/messages.py +++ b/fastmcp_slim/fastmcp/client/messages.py @@ -2,57 +2,32 @@ from typing import TypeAlias import mcp_types from mcp.client.session import MessageHandlerFnT -from mcp.shared.session import RequestResponder -Message: TypeAlias = ( - RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult] - | mcp_types.ServerNotification - | Exception -) +Message: TypeAlias = mcp_types.ServerNotification | Exception MessageHandlerT: TypeAlias = MessageHandlerFnT class MessageHandler: """ - This class is used to handle MCP messages sent to the client. It is used to handle all messages, - requests, notifications, and exceptions. Users can override any of the hooks + This class is used to handle MCP messages sent to the client: notifications + and transport-level exceptions. Users can override any of the hooks. + + Server-initiated *requests* (ping, sampling, roots) never reach this + handler: the stable MCP SDK v2's `message_handler` contract only delivers + `ServerNotification | Exception`, so a request has no wire path here. + Those are answered through the `Client`'s dedicated callbacks instead — + `sampling_handler=`, `roots=`, and `elicitation_handler=`. """ - async def __call__( - self, - message: RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult] - | mcp_types.ServerNotification - | Exception, - ) -> None: + async def __call__(self, message: mcp_types.ServerNotification | Exception) -> None: return await self.dispatch(message) async def dispatch(self, message: Message) -> None: # handle all messages await self.on_message(message) - # SDK v2 delivers server-to-client requests wrapped in a - # RequestResponder (with the request unwrapped on `.request`) and - # notifications unwrapped (the monolith notification model itself, no - # `.root` wrapper). `ServerNotification`/`ServerRequest` are UnionTypes, - # so they can't appear in class match patterns — branch on the concrete - # models directly. - if isinstance(message, RequestResponder): - # handle all requests - # ty doesn't narrow the generic RequestResponder cleanly here. - await self.on_request(message) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - - # handle specific requests - request = message.request - match request: - case mcp_types.PingRequest(): - await self.on_ping(request) - case mcp_types.ListRootsRequest(): - await self.on_list_roots(request) - case mcp_types.CreateMessageRequest(): - await self.on_create_message(request) - - elif isinstance(message, Exception): + if isinstance(message, Exception): await self.on_exception(message) else: @@ -79,20 +54,6 @@ class MessageHandler: async def on_message(self, message: Message) -> None: pass - async def on_request( - self, message: RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult] - ) -> None: - pass - - async def on_ping(self, message: mcp_types.PingRequest) -> None: - pass - - async def on_list_roots(self, message: mcp_types.ListRootsRequest) -> None: - pass - - async def on_create_message(self, message: mcp_types.CreateMessageRequest) -> None: - pass - async def on_notification(self, message: mcp_types.ServerNotification) -> None: pass diff --git a/fastmcp_slim/fastmcp/client/progress.py b/fastmcp_slim/fastmcp/client/progress.py index 826d2cb99..72392305c 100644 --- a/fastmcp_slim/fastmcp/client/progress.py +++ b/fastmcp_slim/fastmcp/client/progress.py @@ -1,6 +1,6 @@ from typing import TypeAlias -from mcp.shared.session import ProgressFnT +from mcp.shared.dispatcher import ProgressFnT from fastmcp.utilities.logging import get_logger diff --git a/fastmcp_slim/fastmcp/client/roots.py b/fastmcp_slim/fastmcp/client/roots.py index cb655c1fb..7db4b9d60 100644 --- a/fastmcp_slim/fastmcp/client/roots.py +++ b/fastmcp_slim/fastmcp/client/roots.py @@ -37,7 +37,7 @@ def create_roots_callback( if isinstance(handler, list): # TODO(ty): remove when ty supports isinstance union narrowing return _create_roots_callback_from_roots(handler) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - elif inspect.isfunction(handler): + elif callable(handler): return _create_roots_callback_from_fn(handler) else: raise ValueError(f"Invalid roots handler: {handler}") diff --git a/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py b/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py index 747d1ab69..f72c4c344 100644 --- a/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py +++ b/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py @@ -75,7 +75,7 @@ class AnthropicSamplingHandler: Example: ```python from anthropic import AsyncAnthropic - from fastmcp import FastMCP + from fastmcp import Client from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler handler = AnthropicSamplingHandler( @@ -83,7 +83,9 @@ class AnthropicSamplingHandler: client=AsyncAnthropic(), ) - server = FastMCP(sampling_handler=handler) + # Answers a handshake-era server's push request and a modern server's + # input-required round alike. + client = Client("https://example.com/mcp", sampling_handler=handler) ``` """ diff --git a/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py b/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py index 7bfbbb626..23a6fa033 100644 --- a/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py +++ b/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py @@ -60,18 +60,20 @@ class GoogleGenaiSamplingHandler: Example: ```python - from google.genai import Client - from fastmcp import FastMCP + from google.genai import Client as GoogleGenaiClient + from fastmcp import Client as FastMCPClient from fastmcp.client.sampling.handlers.google_genai import ( GoogleGenaiSamplingHandler, ) handler = GoogleGenaiSamplingHandler( default_model="gemini-2.0-flash", - client=Client(), + client=GoogleGenaiClient(), ) - server = FastMCP(sampling_handler=handler) + # Answers a handshake-era server's push request and a modern server's + # input-required round alike. + client = FastMCPClient("https://example.com/mcp", sampling_handler=handler) ``` """ diff --git a/fastmcp_slim/fastmcp/client/transports/base.py b/fastmcp_slim/fastmcp/client/transports/base.py index 760e0a2e8..422f3ed9c 100644 --- a/fastmcp_slim/fastmcp/client/transports/base.py +++ b/fastmcp_slim/fastmcp/client/transports/base.py @@ -29,6 +29,7 @@ class ClientSessionKwargs(TypedDict, total=False): sampling_capabilities: mcp_types.SamplingCapability | None list_roots_callback: ListRootsFnT | None logging_callback: LoggingFnT | None + log_level: mcp_types.LoggingLevel | None elicitation_callback: ElicitationFnT | None message_handler: MessageHandlerFnT | None client_info: mcp_types.Implementation | None diff --git a/fastmcp_slim/fastmcp/exceptions.py b/fastmcp_slim/fastmcp/exceptions.py index b74b50998..4042f80b7 100644 --- a/fastmcp_slim/fastmcp/exceptions.py +++ b/fastmcp_slim/fastmcp/exceptions.py @@ -95,6 +95,30 @@ class AuthorizationError(FastMCPError): """Error when authorization check fails.""" +class InsufficientScopeError(AuthorizationError): + """Authorization failed because the token is missing required OAuth scopes. + + Unlike a bare ``AuthorizationError``, this carries the specific scopes the + caller must obtain. A component-level scope shortfall can then be signalled + as a spec-correct ``insufficient_scope`` step-up (SEP-2350 / RFC 6750 §3), + naming exactly what to re-authorize for instead of an opaque denial. The + named scopes are only the *unmet* ones, so an existing grant is accumulated + rather than replaced when the caller re-authorizes. + """ + + def __init__( + self, + required_scopes: list[str], + *, + message: str | None = None, + ) -> None: + self.required_scopes = list(required_scopes) + if message is None: + named = ", ".join(self.required_scopes) or "(unknown)" + message = f"Insufficient scope. Required: {named}" + super().__init__(message) + + def to_mcp_error(exc: Exception, *, default_code: int = INTERNAL_ERROR) -> MCPError: """Translate a FastMCP exception into a wire-format ``MCPError``. diff --git a/fastmcp_slim/fastmcp/experimental/sampling/__init__.py b/fastmcp_slim/fastmcp/experimental/sampling/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/fastmcp_slim/fastmcp/experimental/sampling/handlers/__init__.py b/fastmcp_slim/fastmcp/experimental/sampling/handlers/__init__.py deleted file mode 100644 index 627dfd011..000000000 --- a/fastmcp_slim/fastmcp/experimental/sampling/handlers/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# Re-export for backwards compatibility -# The canonical location is now fastmcp.client.sampling.handlers -from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler - -__all__ = ["OpenAISamplingHandler"] diff --git a/fastmcp_slim/fastmcp/experimental/sampling/handlers/openai.py b/fastmcp_slim/fastmcp/experimental/sampling/handlers/openai.py deleted file mode 100644 index b466f7a77..000000000 --- a/fastmcp_slim/fastmcp/experimental/sampling/handlers/openai.py +++ /dev/null @@ -1,5 +0,0 @@ -# Re-export for backwards compatibility -# The canonical location is now fastmcp.client.sampling.handlers.openai -from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler - -__all__ = ["OpenAISamplingHandler"] diff --git a/fastmcp_slim/fastmcp/prompts/__init__.py b/fastmcp_slim/fastmcp/prompts/__init__.py index d1b866075..b94b5952d 100644 --- a/fastmcp_slim/fastmcp/prompts/__init__.py +++ b/fastmcp_slim/fastmcp/prompts/__init__.py @@ -1,14 +1,6 @@ -import sys - from .function_prompt import FunctionPrompt, prompt from .base import Message, Prompt, PromptArgument, PromptMessage, PromptResult -# Backward compat: prompt.py was renamed to base.py to stop Pyright from resolving -# `from fastmcp.prompts import prompt` as the submodule instead of the decorator function. -# This shim keeps `from fastmcp.prompts.prompt import Prompt` working at runtime. -# Safe to remove once we're confident no external code imports from the old path. -sys.modules[f"{__name__}.prompt"] = sys.modules[f"{__name__}.base"] - __all__ = [ "FunctionPrompt", "Message", diff --git a/fastmcp_slim/fastmcp/prompts/base.py b/fastmcp_slim/fastmcp/prompts/base.py index d23ab0e0f..b7e4fa3f6 100644 --- a/fastmcp_slim/fastmcp/prompts/base.py +++ b/fastmcp_slim/fastmcp/prompts/base.py @@ -10,6 +10,7 @@ import pydantic_core if TYPE_CHECKING: from fastmcp.prompts.function_prompt import FunctionPrompt +import mcp_types from mcp import GetPromptResult from mcp_types import ( AudioContent, @@ -188,6 +189,38 @@ class PromptResult(pydantic.BaseModel): ) +class InputRequiredPromptResult(PromptResult): + """The full result of a single multi-round-trip prompt leg (SEP-2322). + + `InputRequiredResult` is a result type, not a `tools/call` feature: any + request may resolve to one. When a prompt returns an `InputRequiredResult` + from its body to ask the client for input, that ask is the legitimate + result of this `prompts/get` — so FastMCP wraps it in this `PromptResult` + subclass, mirroring `InputRequiredToolResult`, and it flows through the + middleware chain as an ordinary return value. + + Invariant: the wrapped `InputRequiredResult` is never rendered as prompt + messages. `messages` is always empty; the wire handler (`_on_get_prompt`) + reads `.input_required` and returns it to the runner. + """ + + input_required: mcp_types.InputRequiredResult = Field( + description="The client-input request this leg resolved to (SEP-2322)" + ) + + def __init__(self, input_required: mcp_types.InputRequiredResult) -> None: + # Bypass PromptResult's message-normalizing __init__: an input-required + # leg carries no messages (see the invariant above), and + # `input_required` is a required field PromptResult.__init__ can't set. + pydantic.BaseModel.__init__( + self, + messages=[], + description=None, + meta=None, + input_required=input_required, + ) + + class Prompt(FastMCPComponent): """A prompt template that can be rendered with parameters.""" @@ -287,6 +320,12 @@ class Prompt(FastMCPComponent): if isinstance(raw_value, PromptResult): return raw_value + if isinstance(raw_value, mcp_types.InputRequiredResult): + # The prompt asked the client for input (SEP-2322). Wrap it so the + # ask travels the middleware chain as an ordinary result; the wire + # handler unwraps it. + return InputRequiredPromptResult(raw_value) + if isinstance(raw_value, str): return PromptResult(raw_value, description=self.description, meta=self.meta) diff --git a/fastmcp_slim/fastmcp/resources/__init__.py b/fastmcp_slim/fastmcp/resources/__init__.py index cbe819c95..b0e5b4524 100644 --- a/fastmcp_slim/fastmcp/resources/__init__.py +++ b/fastmcp_slim/fastmcp/resources/__init__.py @@ -1,5 +1,3 @@ -import sys - from .function_resource import FunctionResource, resource from .base import Resource, ResourceContent, ResourceResult from .security import ResourceSecurity @@ -26,9 +24,3 @@ __all__ = [ "TextResource", "resource", ] - -# Backward compat: resource.py was renamed to base.py to stop Pyright from resolving -# `from fastmcp.resources import resource` as the submodule instead of the decorator function. -# This shim keeps `from fastmcp.resources.resource import Resource` working at runtime. -# Safe to remove once we're confident no external code imports from the old path. -sys.modules[f"{__name__}.resource"] = sys.modules[f"{__name__}.base"] diff --git a/fastmcp_slim/fastmcp/resources/base.py b/fastmcp_slim/fastmcp/resources/base.py index 7210a8e63..30ed132ea 100644 --- a/fastmcp_slim/fastmcp/resources/base.py +++ b/fastmcp_slim/fastmcp/resources/base.py @@ -210,6 +210,35 @@ class ResourceResult(pydantic.BaseModel): ) +class InputRequiredResourceResult(ResourceResult): + """The full result of a single multi-round-trip resource read (SEP-2322). + + `InputRequiredResult` is a result type, not a `tools/call` feature: any + request may resolve to one. When a resource or resource template returns an + `InputRequiredResult` from its body to ask the client for input, that ask is + the legitimate result of this `resources/read` — so FastMCP wraps it in this + `ResourceResult` subclass, mirroring `InputRequiredToolResult` and + `InputRequiredPromptResult`, and it flows through the middleware chain as an + ordinary return value. + + Invariant: the wrapped `InputRequiredResult` is never serialized as resource + contents. `contents` is always empty; the wire handler (`_on_read_resource`) + reads `.input_required` and returns it to the runner. + """ + + input_required: mcp_types.InputRequiredResult = pydantic.Field( + description="The client-input request this read resolved to (SEP-2322)" + ) + + def __init__(self, input_required: mcp_types.InputRequiredResult) -> None: + # Bypass ResourceResult's content-normalizing __init__: an + # input-required read carries no contents (see the invariant above), and + # `input_required` is a required field ResourceResult.__init__ can't set. + pydantic.BaseModel.__init__( + self, contents=[], meta=None, input_required=input_required + ) + + def _public_content_meta(meta: dict[str, Any] | None) -> dict[str, Any] | None: """Strip FastMCP's internal bookkeeping out of component meta. @@ -259,6 +288,12 @@ def convert_raw_to_resource_result( if isinstance(raw_value, ResourceResult): return raw_value + if isinstance(raw_value, mcp_types.InputRequiredResult): + # The resource asked the client for input (SEP-2322). Wrap it so the + # ask travels the middleware chain as an ordinary result; the wire + # handler unwraps it. + return InputRequiredResourceResult(raw_value) + meta = _public_content_meta(meta) # For plain str/bytes returns, wrap in ResourceContent with the diff --git a/fastmcp_slim/fastmcp/server/auth/__init__.py b/fastmcp_slim/fastmcp/server/auth/__init__.py index 67ee25915..8c1e2631d 100644 --- a/fastmcp_slim/fastmcp/server/auth/__init__.py +++ b/fastmcp_slim/fastmcp/server/auth/__init__.py @@ -8,9 +8,10 @@ from .auth import ( AccessToken, AuthProvider, ) -from .authorization import ( +from fastmcp.utilities.authorization import ( AuthCheck, AuthContext, + require_roles, require_scopes, restrict_tag, run_auth_checks, @@ -75,6 +76,7 @@ __all__ = [ "RemoteAuthProvider", "StaticTokenVerifier", "TokenVerifier", + "require_roles", "require_scopes", "restrict_tag", "run_auth_checks", diff --git a/fastmcp_slim/fastmcp/server/auth/auth.py b/fastmcp_slim/fastmcp/server/auth/auth.py index 10864a58a..6e3b13f42 100644 --- a/fastmcp_slim/fastmcp/server/auth/auth.py +++ b/fastmcp_slim/fastmcp/server/auth/auth.py @@ -4,6 +4,7 @@ import json from typing import TYPE_CHECKING, Any from urllib.parse import urlparse +from mcp.server.auth.handlers.metadata import MetadataHandler from mcp.server.auth.handlers.token import TokenErrorResponse from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler from mcp.server.auth.json_response import PydanticJSONResponse @@ -30,6 +31,7 @@ from mcp.server.auth.provider import ( TokenVerifier as TokenVerifierProtocol, ) from mcp.server.auth.routes import ( + build_metadata, cors_middleware, create_auth_routes, create_protected_resource_routes, @@ -644,10 +646,22 @@ class MultiAuth(AuthProvider): Example: ```python + from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, JWTVerifier, OAuthProxy + upstream = OAuthProxy( + upstream_authorization_endpoint="https://login.example.com/oauth/authorize", + upstream_token_endpoint="https://login.example.com/oauth/token", + upstream_client_id="my-app", + upstream_client_secret="secret", + token_verifier=JWTVerifier( + jwks_uri="https://login.example.com/.well-known/jwks.json" + ), + base_url="https://my-server.com", + ) + auth = MultiAuth( - server=OAuthProxy(issuer_url="https://login.example.com/..."), + server=upstream, verifiers=[JWTVerifier(jwks_uri="https://example.com/.well-known/jwks.json")], ) mcp = FastMCP("my-server", auth=auth) @@ -837,7 +851,8 @@ class OAuthProvider( ): logger.info( f"OAuth endpoints at {self.base_url}, issuer at {self.issuer_url}. " - f"Ensure well-known routes are accessible at root ({self.issuer_url}/.well-known/). " + f"Ensure well-known routes are accessible at root " + f"({str(self.issuer_url).rstrip('/')}/.well-known/). " f"See: https://gofastmcp.com/deployment/http#mounting-authenticated-servers" ) @@ -892,10 +907,10 @@ class OAuthProvider( # Configure resource URL before creating routes self.set_mcp_path(mcp_path) - # Create standard OAuth authorization server routes - # Pass base_url as issuer_url to ensure metadata declares endpoints where - # they're actually accessible (operational routes are mounted at - # base_url) + # Create standard OAuth authorization server routes. Pass base_url so + # the SDK mounts operational routes and declares endpoint URLs where + # they're actually accessible; the metadata route is replaced below so + # that the advertised `issuer` reports issuer_url instead. assert self.base_url is not None # typing check assert ( self.issuer_url is not None @@ -914,6 +929,35 @@ class OAuthProvider( oauth_routes: list[Route] = [] for route in sdk_routes: if ( + isinstance(route, Route) + and route.path == "/.well-known/oauth-authorization-server" + ): + # The SDK bakes the metadata into the handler when it builds the + # route, and derives both `issuer` and every endpoint URL from a + # single argument. Rebuild it here so the endpoints stay on + # base_url (where the routes are mounted) while `issuer` + # reports issuer_url — the identifier clients used for RFC 8414 + # discovery, which §3.3 requires the metadata to match. + metadata = build_metadata( + self.base_url, + self.service_documentation_url, + self.client_registration_options or ClientRegistrationOptions(), + self.revocation_options or RevocationOptions(), + ) + metadata.issuer = self.issuer_url + metadata_handler = MetadataHandler(metadata) + oauth_routes.append( + Route( + path=route.path, + endpoint=cors_middleware( + metadata_handler.handle, ["GET", "OPTIONS"] + ), + methods=route.methods or ["GET", "OPTIONS"], + name=route.name, + include_in_schema=route.include_in_schema, + ) + ) + elif ( isinstance(route, Route) and route.path == "/token" and route.methods is not None diff --git a/fastmcp_slim/fastmcp/server/auth/authorization.py b/fastmcp_slim/fastmcp/server/auth/authorization.py deleted file mode 100644 index ba252f9d8..000000000 --- a/fastmcp_slim/fastmcp/server/auth/authorization.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Backward-compatible exports for component authorization primitives.""" - -from fastmcp.utilities.authorization import ( - AuthCheck, - AuthContext, - require_scopes, - restrict_tag, - run_auth_checks, -) - -__all__ = [ - "AuthCheck", - "AuthContext", - "require_scopes", - "restrict_tag", - "run_auth_checks", -] diff --git a/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py b/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py index b5086ea75..6b7e6eab8 100644 --- a/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py +++ b/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py @@ -177,8 +177,10 @@ class AuthorizationHandler(SDKAuthorizationHandler): def __init__( self, + *, provider: OAuthAuthorizationServerProvider, base_url: AnyHttpUrl | str, + issuer_url: AnyHttpUrl | str | None = None, server_name: str | None = None, server_icon_url: str | None = None, ): @@ -187,14 +189,17 @@ class AuthorizationHandler(SDKAuthorizationHandler): Args: provider: OAuth authorization server provider base_url: Base URL of the server for constructing endpoint URLs + issuer_url: Authorization server issuer identifier. Defaults to + `base_url`, which is correct whenever the server's identity and + its endpoint locations are the same URL. server_name: Optional server name for branding server_icon_url: Optional server icon URL for branding """ super().__init__(provider) # Unnormalized on purpose: this must match the discovery document's # `issuer` field byte-for-byte per RFC 9207, and that field is built - # from the same unmodified base_url (see OAuthProxy.get_routes()). - self._issuer = str(base_url) + # from the same unmodified issuer_url (see OAuthProxy.get_routes()). + self._issuer = str(issuer_url if issuer_url is not None else base_url) self._base_url = str(base_url).rstrip("/") self._server_name = server_name self._server_icon_url = server_icon_url diff --git a/fastmcp_slim/fastmcp/server/auth/identity_assertion.py b/fastmcp_slim/fastmcp/server/auth/identity_assertion.py index b86eceb34..2c503cc06 100644 --- a/fastmcp_slim/fastmcp/server/auth/identity_assertion.py +++ b/fastmcp_slim/fastmcp/server/auth/identity_assertion.py @@ -99,10 +99,12 @@ class IdentityAssertion(BaseModel): audience: str | None = Field( default=None, description=( - "Expected `aud` value on the ID-JAG. When omitted, the audience is the " - "authorization server's own issuer URL (its base URL), which is where the " - "ID-JAG's `aud` must point per SEP-990. Override only when the IdP mints " - "assertions bound to a different audience identifier." + "Expected `aud` value on the ID-JAG. When omitted, the audience is this " + "server's issuer identifier — the `issuer` published in its authorization " + "server metadata, which is `issuer_url` when set and `base_url` otherwise " + "— and that is where the ID-JAG's `aud` must point per SEP-990. Override " + "only when the IdP mints assertions bound to a different audience " + "identifier." ), ) required_scopes: list[str] | None = Field( @@ -216,7 +218,7 @@ class IdentityAssertionValidator: """ self.config = config # Accept the audience both with and without a trailing slash: metadata - # advertises the issuer exactly as pydantic renders base_url (a bare + # advertises the issuer exactly as pydantic renders issuer_url (a bare # domain gains a trailing slash), so an IdP that sets `aud` to the # advertised value verbatim must match, and so must one that strips it. if config.audience: diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py index 753aae198..5eda1d70d 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py @@ -357,7 +357,7 @@ class ConsentMixin: url=build_client_redirect( txn["client_redirect_uri"], callback_params, - iss=str(self.base_url), + iss=str(self.issuer_url), ), status_code=302, ) @@ -532,7 +532,7 @@ class ConsentMixin: "state": txn.get("client_state") or "", } client_callback_url = build_client_redirect( - txn["client_redirect_uri"], callback_params, iss=str(self.base_url) + txn["client_redirect_uri"], callback_params, iss=str(self.issuer_url) ) response = RedirectResponse(url=client_callback_url, status_code=302) diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py index 7668d98fd..6d8770df4 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py @@ -14,6 +14,8 @@ from pydantic import AnyUrl, BaseModel, Field, ValidationError from fastmcp.server.auth.cimd import CIMDDocument from fastmcp.server.auth.redirect_validation import ( + is_loopback_host, + is_redirect_uri_allowed_for_application_type, matches_allowed_pattern, validate_redirect_uri, ) @@ -139,10 +141,6 @@ def _redirect_uri_path(uri_path: str) -> str: return uri_path or "/" -def _is_loopback_host(host: str | None) -> bool: - return host is not None and host.lower() in {"localhost", "127.0.0.1", "::1"} - - def _matches_registered_loopback_redirect_uri( redirect_uri: AnyUrl, registered_uri: AnyUrl, @@ -158,7 +156,7 @@ def _matches_registered_loopback_redirect_uri( requested_host = requested.hostname.lower() if requested.hostname else None registered_host = registered.hostname.lower() if registered.hostname else None - if not _is_loopback_host(registered_host): + if not is_loopback_host(registered_host): return False if requested_host != registered_host: return False @@ -218,6 +216,22 @@ class ProxyDCRClient(OAuthClientInformationFull): cimd_fetched_at: float | None = Field(default=None) allow_unregistered_redirect_uris: bool = Field(default=False, exclude=True) + def _enforce_application_type(self, redirect_uri: AnyUrl) -> None: + """Reject a redirect URI that violates the client's application_type. + + SEP-837: the web/native distinction is enforced at registration, but a + stored web client must not later authorize a loopback or custom-scheme + redirect URI (nor a native client an unsafe scheme), so the same rule is + applied here on the authorization path. + """ + if not is_redirect_uri_allowed_for_application_type( + redirect_uri, self.application_type + ): + raise InvalidRedirectUriError( + f"Redirect URI '{redirect_uri}' is not allowed for " + f"application_type '{self.application_type}'." + ) + def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: """Validate redirect URI against proxy patterns and optionally CIMD redirect_uris. @@ -251,6 +265,7 @@ class ProxyDCRClient(OAuthClientInformationFull): f"Redirect URI '{resolved}' does not match allowed patterns." ) + self._enforce_application_type(resolved) return resolved raise InvalidRedirectUriError( @@ -263,6 +278,8 @@ class ProxyDCRClient(OAuthClientInformationFull): f"Redirect URI '{redirect_uri}' uses an unsafe scheme." ) + self._enforce_application_type(redirect_uri) + cimd_redirect_uris = ( self.cimd_document.redirect_uris if self.cimd_document else None ) @@ -316,4 +333,5 @@ class ProxyDCRClient(OAuthClientInformationFull): raise InvalidRedirectUriError( f"Redirect URI '{resolved}' does not match allowed patterns." ) + self._enforce_application_type(resolved) return resolved diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index 1037439bc..422ed3b49 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -25,6 +25,7 @@ from base64 import urlsafe_b64encode from collections import OrderedDict from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from contextvars import ContextVar from typing import Any, Literal from urllib.parse import urlencode @@ -42,6 +43,7 @@ from key_value.aio.stores.filetree import ( ) from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from mcp.server.auth.handlers.metadata import MetadataHandler +from mcp.server.auth.handlers.register import RegistrationHandler from mcp.server.auth.middleware.client_auth import ClientAuthenticator from mcp.server.auth.provider import ( AccessToken, @@ -58,10 +60,14 @@ from mcp.server.auth.settings import ( ClientRegistrationOptions, RevocationOptions, ) -from mcp.shared.auth import OAuthClientInformationFull, OAuthToken -from pydantic import AnyHttpUrl, AnyUrl, SecretStr +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthToken, +) +from pydantic import AnyHttpUrl, AnyUrl, SecretStr, ValidationError from starlette.requests import Request -from starlette.responses import HTMLResponse, RedirectResponse +from starlette.responses import HTMLResponse, RedirectResponse, Response from starlette.routing import Route from typing_extensions import override @@ -105,6 +111,7 @@ from fastmcp.server.auth.oauth_proxy.ui import create_error_html from fastmcp.server.auth.oauth_proxy.upstream import AsyncOAuth2Client from fastmcp.server.auth.redirect_validation import ( build_client_redirect, + is_redirect_uri_allowed_for_application_type, validate_redirect_uri, ) from fastmcp.utilities.auth import parse_scopes @@ -114,6 +121,52 @@ logger = get_logger(__name__) _REFRESH_LOCK_CACHE_SIZE = 10_000 +#: SEP-837: the client's declared `application_type`, recovered from the raw DCR +#: request body by `_ApplicationTypeRegistrationHandler` before the SDK's +#: `RegistrationHandler` runs. The SDK parses `application_type` into +#: `OAuthClientMetadata` but drops it when it builds the +#: `OAuthClientInformationFull` handed to `register_client`, so this ContextVar +#: is the only place the real value survives into the provider. It is `None` when +#: `register_client` is called directly (outside the HTTP route) or when the body +#: failed to parse, in which case the object's own `application_type` is used. +_pending_application_type: ContextVar[Literal["web", "native"] | None] = ContextVar( + "_pending_application_type", default=None +) + + +class _ApplicationTypeRegistrationHandler: + """Recover the DCR `application_type` the SDK handler drops (SEP-837). + + The SDK's `RegistrationHandler` validates the request body into an + `OAuthClientMetadata` (which carries `application_type`) but omits the field + when constructing the `OAuthClientInformationFull` it passes to + `register_client`. This thin wrapper re-parses `application_type` from the + same request body and publishes it on a ContextVar so `register_client` can + enforce the web/native redirect rules, then delegates to the SDK handler + unchanged. Reading `request.body()` here is safe: Starlette caches the body, + so the SDK handler's own read returns the same bytes. + """ + + def __init__(self, handler: RegistrationHandler) -> None: + self._handler = handler + + async def handle(self, request: Request) -> Response: + application_type: Literal["web", "native"] | None = None + try: + metadata = OAuthClientMetadata.model_validate_json(await request.body()) + except ValidationError: + # Let the SDK handler surface the validation error verbatim. + application_type = None + else: + application_type = metadata.application_type + + token = _pending_application_type.set(application_type) + try: + return await self._handler.handle(request) + finally: + _pending_application_type.reset(token) + + #: Marker claim identifying a FastMCP access token minted from a SEP-990 ID-JAG. #: These tokens are self-contained (they carry the asserted subject directly) and #: are validated without the upstream token-swap that regular proxy tokens use. @@ -635,14 +688,17 @@ class OAuthProxy(OAuthProvider, ConsentMixin): allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, ) - # Identity assertion (SEP-990 ID-JAG): the audience the ID-JAG must be - # bound to is this authorization server's own issuer URL (base_url). + # Identity assertion (SEP-990 ID-JAG): per RFC 7523 §3 the `aud` must + # identify this authorization server, and an authorization server is + # identified by its issuer — the same value published as `issuer` in + # the authorization server metadata, which is `issuer_url` (defaulting + # to `base_url`). self._identity_assertion: IdentityAssertion | None = identity_assertion self._identity_assertion_validator: IdentityAssertionValidator | None = None if identity_assertion is not None: self._identity_assertion_validator = IdentityAssertionValidator( config=identity_assertion, - audience=str(self.base_url), + audience=str(self.issuer_url), ) # ID-JAG access tokens are self-contained (no upstream token or JTI # mapping to delete), so revocation tracks their jtis here until the @@ -705,9 +761,11 @@ class OAuthProxy(OAuthProvider, ConsentMixin): super().set_mcp_path(mcp_path) # Create JWT issuer with correct audience based on actual MCP path - # This ensures tokens are bound to the specific resource URL + # This ensures tokens are bound to the specific resource URL. The `iss` + # claim is the authorization server's issuer identifier (`issuer_url`), + # which matches the `issuer` advertised in the metadata document. self._jwt_issuer = JWTIssuer( - issuer=str(self.base_url), + issuer=str(self.issuer_url), audience=str(self._resource_url), signing_key=self._jwt_signing_key, ) @@ -728,6 +786,19 @@ class OAuthProxy(OAuthProvider, ConsentMixin): ) return self._jwt_issuer + @property + def token_endpoint_url(self) -> str: + """The token endpoint URL, as advertised in the authorization server metadata. + + A CIMD `private_key_jwt` assertion is bound to this URL as its `aud`, so + it must match the advertised `token_endpoint` byte-for-byte. The SDK's + `build_metadata` builds that URL by stripping any trailing slash from + `base_url` first, so this does too: pydantic renders a bare-authority + `base_url` with a trailing slash, which would otherwise expect an `aud` + of `https://example.com//token`. + """ + return f"{str(self.base_url).rstrip('/')}/token" + # ------------------------------------------------------------------------- # Upstream OAuth Client # ------------------------------------------------------------------------- @@ -907,6 +978,25 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Create a ProxyDCRClient with configured redirect URI validation if client_info.client_id is None: raise ValueError("client_id is required for client registration") + + # SEP-837: the SDK's RegistrationHandler drops application_type when it + # builds this object, so prefer the value the HTTP route recovered from + # the raw request body. Fall back to the object's own field for direct + # (non-HTTP) callers. Write it back so the DCR response echoes the type. + # + # The SDK splits the registration *request* model from the registered + # *client record*: `OAuthClientMetadata.application_type` defaults to + # "native", while `OAuthClientInformationFull.application_type` is + # `str | None` and defaults to None. Normalize the unset case back to + # "native" so a client that omits the field gets the RFC 7591 default + # recorded explicitly, on both the HTTP and direct-call paths. + pending_application_type = _pending_application_type.get() + if pending_application_type is not None: + client_info.application_type = pending_application_type + elif client_info.application_type is None: + client_info.application_type = "native" + application_type = client_info.application_type + if client_info.redirect_uris: for redirect_uri in client_info.redirect_uris: if not validate_redirect_uri( @@ -917,6 +1007,28 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "invalid_redirect_uri", f"Redirect URI '{redirect_uri}' is not allowed.", ) + # SEP-837: honor the client's declared application_type. "web" + # clients are restricted to non-loopback https redirect URIs. + if not is_redirect_uri_allowed_for_application_type( + redirect_uri, + application_type, + ): + raise RegistrationError( + "invalid_redirect_uri", + f"Redirect URI '{redirect_uri}' is not allowed for " + f"application_type '{application_type}'.", + ) + elif application_type == "web": + # Clients may omit redirect_uris and supply one at authorization, + # which falls back to the `http://localhost` placeholder below. A web + # client can never authorize against that placeholder (loopback http + # fails its own rule), so registering one would only produce a client + # that is guaranteed to fail later. Refuse it now, with a reason. + raise RegistrationError( + "invalid_redirect_uri", + "redirect_uris is required for application_type 'web'; web " + "clients must register a non-loopback https redirect URI.", + ) redirect_uris = client_info.redirect_uris or [AnyUrl("http://localhost")] @@ -945,6 +1057,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): grant_types=registered_grant_types, scope=client_info.scope or self._default_scope_str, token_endpoint_auth_method="none", + application_type=application_type, allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, client_name=getattr(client_info, "client_name", None), ) @@ -1057,9 +1170,17 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Store transaction data for IdP callback processing if client.client_id is None: raise AuthorizeError( - error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type # ty:ignore[invalid-argument-type] + error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type error_description="Client ID is required", ) + # Clients may omit `scope` entirely, in which case OAuth lets the + # authorization server apply its configured default. Resolve that default + # once, here, so the transaction records the scopes actually being + # authorized. Every later consumer — the consent screen, the issued + # authorization code, token exchange, and refresh — reads this one value + # instead of deciding for itself whether to substitute required_scopes. + effective_scopes = params.scopes or self.required_scopes or [] + transaction = OAuthTransaction( txn_id=txn_id, client_id=client.client_id, @@ -1067,7 +1188,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): client_state=params.state or "", code_challenge=params.code_challenge, code_challenge_method=getattr(params, "code_challenge_method", "S256"), - scopes=params.scopes or [], + scopes=effective_scopes, created_at=time.time(), resource=getattr(params, "resource", None), proxy_code_verifier=proxy_code_verifier, @@ -1142,7 +1263,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Create authorization code object with PKCE challenge if client.client_id is None: raise AuthorizeError( - error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type # ty:ignore[invalid-argument-type] + error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type error_description="Client ID is required", ) return AuthorizationCode( @@ -2294,6 +2415,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): authorize_handler = AuthorizationHandler( provider=self, base_url=self.base_url, # ty: ignore[invalid-argument-type] + issuer_url=self.issuer_url, server_name=None, # Could be extended to pass server metadata server_icon_url=None, ) @@ -2314,7 +2436,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Replace the token endpoint so it can (a) authenticate CIMD # private_key_jwt clients and (b) accept the SEP-990 jwt-bearer # grant when identity assertion is enabled. - token_endpoint_url = f"{self.base_url}/token" + token_endpoint_url = self.token_endpoint_url if self._cimd_manager is not None: authenticator: ClientAuthenticator = ( PrivateKeyJWTClientAuthenticator( @@ -2339,6 +2461,35 @@ class OAuthProxy(OAuthProvider, ConsentMixin): methods=["POST", "OPTIONS"], ) ) + elif ( + isinstance(route, Route) + and route.path == "/register" + and route.methods is not None + and "POST" in route.methods + ): + # SEP-837: wrap the SDK RegistrationHandler so the client's + # declared application_type (which the SDK parses but drops before + # calling register_client) survives into the provider and its + # web/native redirect rules are enforced over HTTP. + registration_options = ( + self.client_registration_options or ClientRegistrationOptions() + ) + sdk_registration_handler = RegistrationHandler( + provider=self, + options=registration_options, + ) + registration_handler = _ApplicationTypeRegistrationHandler( + sdk_registration_handler + ) + custom_routes.append( + Route( + path="/register", + endpoint=cors_middleware( + registration_handler.handle, ["POST", "OPTIONS"] + ), + methods=["POST", "OPTIONS"], + ) + ) elif isinstance(route, Route) and route.path.startswith( "/.well-known/oauth-authorization-server" ): @@ -2353,6 +2504,14 @@ class OAuthProxy(OAuthProvider, ConsentMixin): revocation_options, supports_identity_assertion=self._identity_assertion is not None, ) + # `build_metadata` derives both the `issuer` field and every + # endpoint URL from a single argument. Endpoints must stay on + # `base_url` (that is where the routes are actually mounted), + # while the issuer identity is `issuer_url`. RFC 8414 §3.3 + # requires `issuer` to match the URL the client used for + # discovery, which is the `issuer_url` advertised in the + # protected resource metadata. + metadata.issuer = self.issuer_url # ty: ignore[invalid-assignment] # RFC 9207: every authorization response we issue carries an # `iss` matching this issuer byte-for-byte, so this route must # always be overridden to advertise support — not just when @@ -2470,7 +2629,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): url=build_client_redirect( client_redirect_uri, error_params, - iss=str(self.base_url), + iss=str(self.issuer_url), ), status_code=302, ) @@ -2638,7 +2797,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): } client_callback_url = build_client_redirect( - client_redirect_uri, callback_params, iss=str(self.base_url) + client_redirect_uri, callback_params, iss=str(self.issuer_url) ) logger.debug(f"Forwarding to client callback for transaction {txn_id}") diff --git a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py index 8f17c75b8..e2bda58ab 100644 --- a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py @@ -226,6 +226,7 @@ class OIDCProxy(OAuthProxy): redirect_path: str | None = None, # Client configuration allowed_client_redirect_uris: list[str] | None = None, + valid_scopes: list[str] | None = None, client_storage: AsyncKeyValue | None = None, # JWT and encryption keys jwt_signing_key: str | bytes | None = None, @@ -284,6 +285,15 @@ class OIDCProxy(OAuthProxy): ports allowed to vary for MCP compatibility. Unsafe browser schemes are rejected. If empty list, no redirect URIs are allowed. These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. + valid_scopes: The complete set of scopes clients are allowed to request, + advertised to clients via the `/.well-known` endpoints (as + `scopes_supported`) and enforced at Dynamic Client Registration: a + client that registers requesting a scope outside this set is rejected. + This is a superset of `required_scopes`, which is only the floor + enforced during token validation. Defaults to `required_scopes` when + not provided, so permitting optional scopes beyond the required floor + means setting this explicitly. Valid whether or not a custom + `token_verifier` is supplied. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). If None, an encrypted file store will be created in the data directory (derived from `platformdirs`). @@ -414,6 +424,7 @@ class OIDCProxy(OAuthProxy): "issuer_url": issuer_url or base_url, "service_documentation_url": self.oidc_config.service_documentation, "allowed_client_redirect_uris": allowed_client_redirect_uris, + "valid_scopes": valid_scopes, "client_storage": client_storage, "jwt_signing_key": jwt_signing_key, "token_endpoint_auth_method": token_endpoint_auth_method, @@ -454,14 +465,16 @@ class OIDCProxy(OAuthProxy): self._verify_id_token = verify_id_token - # When verify_id_token strips scopes from the verifier, restore - # them on the provider so they're still advertised to clients - # and enforced at the FastMCP token level. We also need to - # recompute derived state that OAuthProxy.__init__ already built - # from the (empty) verifier scopes. - if verify_id_token and required_scopes: - self.required_scopes = required_scopes - self.update_default_scopes(required_scopes) + # When verify_id_token strips scopes from the verifier, restore the + # derived scope state OAuthProxy.__init__ built from the (empty) verifier + # scopes. required_scopes is the enforcement floor; the advertised and + # registerable set is the broader valid_scopes when one was given. + if verify_id_token: + if required_scopes: + self.required_scopes = required_scopes + advertised_scopes = valid_scopes or required_scopes + if advertised_scopes: + self.update_default_scopes(advertised_scopes) def _get_verification_token( self, upstream_token_set: UpstreamTokenSet diff --git a/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py b/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py index de89372eb..22b6c5d6b 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py @@ -209,6 +209,9 @@ class HuggingFaceProvider(OAuthProxy): client_secret: Hugging Face OAuth app client secret. Optional for public PKCE apps; when omitted, ``jwt_signing_key`` is required. base_url: Public URL where OAuth endpoints will be accessible. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use + root-level URL to avoid 404s during discovery when mounting under + a path. required_scopes: Required Hugging Face scopes. Defaults to ``["openid", "profile"]``. valid_scopes: Scopes clients may request. Defaults to required scopes. diff --git a/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py b/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py index 3ae686dac..67068bd58 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py @@ -36,8 +36,10 @@ class InMemoryOAuthProvider(OAuthProvider): def __init__( self, + *, base_url: AnyHttpUrl | str | None = None, resource_base_url: AnyHttpUrl | str | None = None, + issuer_url: AnyHttpUrl | str | None = None, service_documentation_url: AnyHttpUrl | str | None = None, client_registration_options: ClientRegistrationOptions | None = None, revocation_options: RevocationOptions | None = None, @@ -46,6 +48,7 @@ class InMemoryOAuthProvider(OAuthProvider): super().__init__( base_url=base_url or "http://fastmcp.example.com", resource_base_url=resource_base_url, + issuer_url=issuer_url, service_documentation_url=service_documentation_url, client_registration_options=client_registration_options, revocation_options=revocation_options, diff --git a/fastmcp_slim/fastmcp/server/auth/providers/oci.py b/fastmcp_slim/fastmcp/server/auth/providers/oci.py index f5fb8bea7..ffd98ed09 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/oci.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/oci.py @@ -156,7 +156,10 @@ class OCIProvider(OIDCProxy): resource_base_url: Optional public base URL for the protected resource metadata and token audience. Defaults to ``base_url``. audience: OCI API audience (optional) - issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). This is + this server's own OAuth identity, not the OCI IAM Domain's — it has no + effect on the upstream issuer taken from the discovery URL. Use a + root-level URL to avoid 404s during discovery when mounting under a path. required_scopes: Required OCI scopes (defaults to ["openid"]) redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback". allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. diff --git a/fastmcp_slim/fastmcp/server/auth/redirect_validation.py b/fastmcp_slim/fastmcp/server/auth/redirect_validation.py index fb7cba969..9bb3c1e69 100644 --- a/fastmcp_slim/fastmcp/server/auth/redirect_validation.py +++ b/fastmcp_slim/fastmcp/server/auth/redirect_validation.py @@ -5,6 +5,7 @@ protecting against userinfo-based bypass attacks like http://localhost@evil.com. """ import fnmatch +import ipaddress from urllib.parse import unquote, urlencode, urlparse, urlunparse from pydantic import AnyUrl @@ -170,15 +171,58 @@ def _match_host(uri_host: str | None, pattern_host: str | None) -> bool: return uri_host == pattern_host -def _is_loopback_host(host: str | None) -> bool: +def is_loopback_host(host: str | None) -> bool: """Check if a host is a loopback address. - Per RFC 8252 §7.3, loopback addresses include localhost, 127.0.0.1, and ::1. + Per RFC 8252 §7.3, loopback covers the whole reserved loopback range, not + just the two familiar literals: IPv4 `127.0.0.0/8` (so `127.0.0.2` and + `127.5.5.5` are loopback just as much as `127.0.0.1`) and IPv6 `::1`. IP + hosts are therefore classified with `ipaddress.ip_address().is_loopback` + rather than string equality — checking only `127.0.0.1` would let a web + client register `https://127.0.0.2/callback` and slip past the + non-loopback requirement. + + Names are handled per RFC 6761 §6.3, which reserves the entire `localhost` + namespace for the local machine: the exact name `localhost` *and* any + subdomain of it (`app.localhost`, `api.app.localhost`). `.localhost` is a + reserved TLD that cannot be registered, so a subdomain of it always resolves + to the loopback interface and must count as loopback in both directions — + otherwise a web client could register `https://app.localhost/callback` and + slip past the non-loopback requirement, while a native client using + `http://app.localhost:3000/callback` would be wrongly rejected. + + The suffix test is anchored on a leading dot so it cannot be spoofed by a + registrable domain: `localhost.evil.com` and `notlocalhost` are ordinary + public names and are *not* loopback. + + Hosts are also normalized before classification: bracketed IPv6 literals + (`[::1]`) are unwrapped, and a single trailing dot (the absolute/FQDN form, + e.g. `localhost.` or `127.0.0.1.`) is stripped, since it denotes the same + host. Non-IP hosts fall through to the name check without raising. """ if not host: return False + host = host.lower() - return host in ("localhost", "127.0.0.1", "::1") + + # urlparse().hostname strips brackets, but callers that parse the netloc + # themselves may still pass a bracketed IPv6 literal. + if host.startswith("[") and host.endswith("]"): + host = host[1:-1] + + # Absolute (fully qualified) form: `localhost.` and `127.0.0.1.` name the + # same hosts as their relative spellings. + if host.endswith("."): + host = host[:-1] + + if not host: + return False + + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + # Not an IP literal — RFC 6761 §6.3 reserved localhost namespace. + return host == "localhost" or host.endswith(".localhost") def _match_port( @@ -314,7 +358,7 @@ def matches_allowed_pattern(uri: str, pattern: str) -> bool: return False # RFC 8252 §7.3: loopback patterns without an explicit port match any port - if not (_is_loopback_host(pattern_host) and pattern_port is None): + if not (is_loopback_host(pattern_host) and pattern_port is None): if not _match_port(uri_port, pattern_port, uri_parsed.scheme.lower()): return False @@ -322,6 +366,64 @@ def matches_allowed_pattern(uri: str, pattern: str) -> bool: return _match_path(uri_parsed.path, pattern_parsed.path) +def is_redirect_uri_allowed_for_application_type( + redirect_uri: str | AnyUrl, + application_type: str | None, +) -> bool: + """Check a redirect URI against RFC 7591 / SEP-837 `application_type` rules. + + `application_type` governs which redirect URIs a Dynamically Registered + Client may use (RFC 7591 §2, OpenID Connect Dynamic Client Registration §2): + + - `"web"` clients must use `https` redirect URIs on a non-loopback host. + Loopback `http`, `https://localhost`, and app/custom schemes are rejected. + This is the restriction SEP-837 actually asks for. + - `"native"` clients keep every scheme FastMCP already allowed, except that + `http` is restricted to loopback hosts (RFC 8252 §7.3, any port). App and + private-use schemes pass through untouched: `vscode://`, + `com.example.app:/callback`, `myapp://callback`, `urn:ietf:wg:oauth:2.0:oob`. + + Deliberately absent: any attempt to classify a native client's scheme as + "private-use" versus "a network transport". There is no sound test. The IANA + registry cannot separate them — `vscode` is registered *because* it is an + app-dispatch scheme, alongside transports like `coap` and `smb` — and + reverse-domain notation fails too, since `iris.beep` and + `microsoft.windows.camera` are registered while `myapp` is not. Every + formulation either rejects schemes real MCP clients depend on or admits the + ones it meant to exclude, so native scheme filtering is left to the + unsafe-scheme check below. + + Unsafe browser schemes (`javascript:`, `data:`, `file:`, `vbscript:`) are + always rejected regardless of `application_type`. That check predates this + function and is unchanged by it. + + The MCP SDK defaults `application_type` to `"native"` because MCP clients + typically register loopback redirect URIs, so omitting the field preserves + the behavior clients relied on before this check existed. `None` — which a + registered-client record carries when the field was never set — is treated + the same way. + """ + uri_str = str(redirect_uri) + + if _is_unsafe_redirect_uri(uri_str): + return False + + parsed = urlparse(uri_str) + scheme = parsed.scheme.lower() + + if application_type == "web": + # "web": require an https redirect URI on a non-loopback host. + if scheme != "https": + return False + return not is_loopback_host(parsed.hostname) + + # "native" (and the SDK default): cleartext http only to a loopback host; + # every other non-unsafe scheme is left alone. + if scheme == "http": + return is_loopback_host(parsed.hostname) + return True + + def validate_redirect_uri( redirect_uri: str | AnyUrl | None, allowed_patterns: list[str] | None, diff --git a/fastmcp_slim/fastmcp/server/context.py b/fastmcp_slim/fastmcp/server/context.py index c0977e7aa..bd089289a 100644 --- a/fastmcp_slim/fastmcp/server/context.py +++ b/fastmcp_slim/fastmcp/server/context.py @@ -1,9 +1,8 @@ from __future__ import annotations import logging -import warnings import weakref -from collections.abc import Callable, Generator, Mapping, Sequence +from collections.abc import Callable, Generator, Mapping from contextlib import contextmanager from contextvars import ContextVar, Token from dataclasses import dataclass @@ -16,9 +15,6 @@ from mcp import LoggingLevel, ServerSession from mcp.server.context import ServerRequestContext from mcp_types import ( GetPromptResult, - ModelPreferences, - Root, - SamplingMessage, ) from mcp_types import Prompt as SDKPrompt from mcp_types import Resource as SDKResource @@ -27,11 +23,7 @@ from pydantic.networks import AnyUrl from typing_extensions import TypeVar from uncalled_for import SharedContext -import fastmcp -from fastmcp.exceptions import ( - FastMCPDeprecationWarning, - ToolError, -) +from fastmcp.exceptions import ToolError from fastmcp.resources.base import ResourceResult from fastmcp.server.dependencies import FastMCPRequestContext, fastmcp_request_ctx from fastmcp.server.elicitation import ( @@ -42,11 +34,6 @@ from fastmcp.server.elicitation import ( parse_elicit_response_type, ) from fastmcp.server.low_level import client_supports_extension -from fastmcp.server.sampling import SampleStep, SamplingResult, SamplingTool -from fastmcp.server.sampling.run import ( - sample_impl, - sample_step_impl, -) from fastmcp.server.server import FastMCP, StateValue from fastmcp.server.transforms.visibility import ( Visibility, @@ -79,30 +66,6 @@ _clamp_logger(logger=to_client_logger, max_level="DEBUG") T = TypeVar("T", default=Any) -ResultT = TypeVar("ResultT", default=str) - -# Import ToolChoiceOption from sampling module (after other imports) -from fastmcp.server.sampling.run import ToolChoiceOption # noqa: E402 - -# Warn-once guard for the sampling deprecation. Server-initiated createMessage -# was removed from MCP as of 2026-07-28 (SEP-2577); the warning fires a single -# time per process to flag that ctx.sample/ctx.sample_step are on their way out. -# A mutable set (mutated in place, never rebound) rather than a `global` boolean -# so the warn-once state is unambiguously read and written from the module. -_sample_deprecation_warned: set[bool] = set() - -_SAMPLING_DEPRECATION_MESSAGE = ( - "ctx.sample() and ctx.sample_step() are deprecated and will be removed in a " - "future FastMCP release. They rely on server-initiated createMessage " - "requests, which were removed from MCP as of 2026-07-28 (SEP-2577), so they " - "work only on session-based (handshake-era) connections. Call an LLM " - "directly from your server instead." -) - -_SAMPLING_MODERN_ERROR = ( - "server-initiated sampling is not available on MCP 2026-07-28 connections; " - "SEP-2577 removed it — call an LLM from your server instead." -) _ELICIT_MODERN_ERROR = ( "elicitation via server-initiated requests is unavailable on 2026-07-28 " @@ -110,22 +73,6 @@ _ELICIT_MODERN_ERROR = ( ) -def _warn_sampling_deprecated() -> None: - """Emit the sampling deprecation warning once per process. - - Gated on ``settings.deprecation_warnings`` like every other FastMCP - deprecation; fires a single time (module-level flag) rather than per call. - """ - if _sample_deprecation_warned or not fastmcp.settings.deprecation_warnings: - return - _sample_deprecation_warned.add(True) - warnings.warn( - _SAMPLING_DEPRECATION_MESSAGE, - FastMCPDeprecationWarning, - stacklevel=3, - ) - - _current_context: ContextVar[Context | None] = ContextVar("context", default=None) @@ -278,9 +225,8 @@ class Context: def is_background_task(self) -> bool: """True when this context is running in a background task (Docket worker). - When True, certain operations like elicit() and sample() will use - task-aware implementations that can pause the task and wait for - client input. + When True, certain operations like elicit() will use task-aware + implementations that can pause the task and wait for client input. Example: ```python @@ -936,13 +882,6 @@ class Context: extra=extra, ) - async def list_roots(self) -> list[Root]: - """List the roots available to the server, as indicated by the client.""" - # Deprecated upstream in SDK v2 but deliberately kept per compat directive; - # removed with the multi-round-trip follow-up. - result = await self.session.list_roots() # ty: ignore[deprecated] - return result.roots - async def send_notification( self, notification: mcp_types.ServerNotification ) -> None: @@ -953,7 +892,15 @@ class Context: """ # v2: ServerNotification is a union of concrete notification models; # ServerSession.send_notification takes an instance directly (no wrapper). - await self.session.send_notification(notification) + # + # Relate the notification to the in-flight request so it rides that + # request's own stream. A sessionless (2026-07-28) connection has no + # standing server→client channel, so an unrelated notification is + # dropped; the request's stream is the only way out. Session-based eras + # deliver it either way. + await self.session.send_notification( + notification, related_request_id=self.request_id + ) async def close_sse_stream(self) -> None: """Close the current response stream to trigger client reconnection. @@ -1008,242 +955,6 @@ class Context: return False return rc.protocol_version in MODERN_PROTOCOL_VERSIONS - def _server_can_sample(self) -> bool: - """True when a server-configured sampling handler can serve the request - without the client back-channel. - - FastMCP supports a server-side sampling handler (``FastMCP(sampling_handler=...)``). - With ``sampling_handler_behavior="always"`` the handler always answers; - with ``"fallback"`` it answers whenever the client cannot. On modern - connections the client back-channel is gone, so either configuration lets - the server answer entirely server-side as long as a handler is set. (For - ``"always"`` without a handler the sampling implementation raises its own - clear "no handler configured" error, which is not an era concern.) - """ - fastmcp = self.fastmcp - if fastmcp.sampling_handler_behavior == "always": - return True - return fastmcp.sampling_handler is not None - - async def sample_step( - self, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, - tool_choice: ToolChoiceOption | str | None = None, - execute_tools: bool = True, - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, - ) -> SampleStep: - """ - Make a single LLM sampling call. - - This is a stateless function that makes exactly one LLM call and optionally - executes any requested tools. Use this for fine-grained control over the - sampling loop. - - Args: - messages: The message(s) to send. Can be a string, list of strings, - or list of SamplingMessage objects. - system_prompt: Optional system prompt for the LLM. - temperature: Optional sampling temperature. - max_tokens: Maximum tokens to generate. Defaults to 512. - model_preferences: Optional model preferences. - tools: Optional list of tools the LLM can use. - tool_choice: Tool choice mode ("auto", "required", or "none"). - execute_tools: If True (default), execute tool calls and append results - to history. If False, return immediately with tool_calls available - in the step for manual execution. - mask_error_details: If True, mask detailed error messages from tool - execution. When None (default), uses the global settings value. - Tools can raise ToolError to bypass masking. - tool_concurrency: Controls parallel execution of tools: - - None (default): Sequential execution (one at a time) - - 0: Unlimited parallel execution - - N > 0: Execute at most N tools concurrently - If any tool has sequential=True, all tools execute sequentially - regardless of this setting. - - Returns: - SampleStep containing: - - .response: The raw LLM response - - .history: Messages including input, assistant response, and tool results - - .is_tool_use: True if the LLM requested tool execution - - .tool_calls: List of tool calls (if any) - - .text: The text content (if any) - - Example: - messages = "Research X" - - while True: - step = await ctx.sample_step(messages, tools=[search]) - - if not step.is_tool_use: - print(step.text) - break - - # Continue with tool results - messages = step.history - """ - _warn_sampling_deprecated() - # On modern (2026-07-28) connections the client back-channel is gone - # (SEP-2577). A server-configured sampling handler can still answer - # entirely server-side; only raise the era error when nothing can serve - # the request. When modern, force the handler path (never attempt the - # dead client) by passing client_available=False. - client_available = not self._is_modern_protocol() - if not client_available and not self._server_can_sample(): - raise ToolError(_SAMPLING_MODERN_ERROR) - return await sample_step_impl( - self, - messages=messages, - system_prompt=system_prompt, - temperature=temperature, - max_tokens=max_tokens, - model_preferences=model_preferences, - tools=tools, - tool_choice=tool_choice, - auto_execute_tools=execute_tools, - mask_error_details=mask_error_details, - tool_concurrency=tool_concurrency, - client_available=client_available, - ) - - @overload - async def sample( - self, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, - result_type: type[ResultT], - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, - ) -> SamplingResult[ResultT]: - """Overload: With result_type, returns SamplingResult[ResultT].""" - - @overload - async def sample( - self, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, - result_type: None = None, - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, - ) -> SamplingResult[str]: - """Overload: Without result_type, returns SamplingResult[str].""" - - async def sample( - self, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, - result_type: type[ResultT] | None = None, - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, - ) -> SamplingResult[ResultT] | SamplingResult[str]: - """ - Send a sampling request to the client and await the response. - - This method runs to completion automatically. When tools are provided, - it executes a tool loop: if the LLM returns a tool use request, the tools - are executed and the results are sent back to the LLM. This continues - until the LLM provides a final text response. - - When result_type is specified, a synthetic `final_response` tool is - created. The LLM calls this tool to provide the structured response, - which is validated against the result_type and returned as `.result`. - - For fine-grained control over the sampling loop, use sample_step() instead. - - Args: - messages: The message(s) to send. Can be a string, list of strings, - or list of SamplingMessage objects. - system_prompt: Optional system prompt for the LLM. - temperature: Optional sampling temperature. - max_tokens: Maximum tokens to generate. Defaults to 512. - model_preferences: Optional model preferences. - tools: Optional list of tools the LLM can use. Accepts plain - functions or SamplingTools. - result_type: Optional type for structured output. When specified, - a synthetic `final_response` tool is created and the LLM's - response is validated against this type. - mask_error_details: If True, mask detailed error messages from tool - execution. When None (default), uses the global settings value. - Tools can raise ToolError to bypass masking. - tool_concurrency: Controls parallel execution of tools: - - None (default): Sequential execution (one at a time) - - 0: Unlimited parallel execution - - N > 0: Execute at most N tools concurrently - If any tool has sequential=True, all tools execute sequentially - regardless of this setting. - - Returns: - SamplingResult[T] containing: - - .text: The text representation (raw text or JSON for structured) - - .result: The typed result (str for text, parsed object for structured) - - .history: All messages exchanged during sampling - - Deprecated: - Server-initiated sampling relies on the createMessage back-channel, - which MCP removed as of 2026-07-28 (SEP-2577). This method works only - on session-based (handshake-era) connections and will be removed in a - future FastMCP release. Call an LLM directly from your server instead. - """ - _warn_sampling_deprecated() - # On modern (2026-07-28) connections the client back-channel is gone - # (SEP-2577). A server-configured sampling handler can still answer - # entirely server-side; only raise the era error when nothing can serve - # the request. When modern, force the handler path (never attempt the - # dead client) by passing client_available=False. - client_available = not self._is_modern_protocol() - if not client_available and not self._server_can_sample(): - raise ToolError(_SAMPLING_MODERN_ERROR) - return await sample_impl( # ty: ignore[invalid-return-type] - self, - messages=messages, - system_prompt=system_prompt, - temperature=temperature, - max_tokens=max_tokens, - model_preferences=model_preferences, - tools=tools, - result_type=result_type, - mask_error_details=mask_error_details, - tool_concurrency=tool_concurrency, - client_available=client_available, - ) - - @overload - async def elicit( - self, - message: str, - response_type: None, - *, - response_title: str | None = None, - response_description: str | None = None, - ) -> ( - AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation - ): ... - - """When response_type is None, the accepted elicitation will contain an - empty dict""" - @overload async def elicit( self, @@ -1254,8 +965,7 @@ class Context: response_description: str | None = None, ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ... - """When response_type is not None, the accepted elicitation will contain the - response data""" + """The accepted elicitation will contain the response data""" @overload async def elicit( @@ -1321,8 +1031,7 @@ class Context: | list[str] | dict[str, dict[str, str]] | list[list[str]] - | list[dict[str, dict[str, str]]] - | None = None, + | list[dict[str, dict[str, str]]], *, response_title: str | None = None, response_description: str | None = None, @@ -1347,11 +1056,9 @@ class Context: "value" field will be generated for the MCP interaction and automatically deconstructed into the primitive type upon response. - Passing ``response_type=None`` (or omitting it) is deprecated and will - be removed in a future version. The resulting empty-schema form-mode - request is ambiguous and causes some clients (e.g. VS Code) to hang on - an empty form. Pass an explicit ``response_type`` describing the data - you want back. + ``response_type`` is required. Pass ``bool`` when all you need is a + confirmation; an empty schema leaves some clients rendering an empty, + non-functional form. Args: message: A human-readable message explaining what information is needed @@ -1373,17 +1080,6 @@ class Context: the guard pattern: return an ``InputRequiredResult`` and read ``ctx.input_responses`` / ``ctx.request_state`` when the task re-runs. """ - if response_type is None and fastmcp.settings.deprecation_warnings: - warnings.warn( - "Calling ctx.elicit() without a response_type is deprecated " - "and will be removed in a future version. The empty-schema " - "form-mode request is ambiguous under the current MCP spec " - "and causes some clients (e.g. VS Code) to render an empty, " - "non-functional form. Pass an explicit response_type " - "describing the data you expect back.", - FastMCPDeprecationWarning, - stacklevel=2, - ) config = parse_elicit_response_type( response_type, response_title=response_title, diff --git a/fastmcp_slim/fastmcp/server/elicitation.py b/fastmcp_slim/fastmcp/server/elicitation.py index 8a3fe0a5e..5eaccf399 100644 --- a/fastmcp_slim/fastmcp/server/elicitation.py +++ b/fastmcp_slim/fastmcp/server/elicitation.py @@ -129,6 +129,17 @@ class ElicitConfig: is_raw: bool +#: Raised when a response type is missing. The empty-object schema this used to +#: produce was ambiguous under the MCP spec and left some clients (e.g. VS Code) +#: rendering an empty, non-functional form. Deprecated in 3.2, removed in 4.0. +_NONE_RESPONSE_TYPE_ERROR = ( + "ctx.elicit() requires a response_type. The empty-schema form-mode request " + "produced by response_type=None was ambiguous under the MCP spec and caused " + "some clients to render an empty, non-functional form. Pass a type " + "describing the data you expect back — use `bool` for a confirmation." +) + + def parse_elicit_response_type( response_type: Any, response_title: str | None = None, @@ -136,8 +147,8 @@ def parse_elicit_response_type( ) -> ElicitConfig: """Parse response_type into schema and handling configuration. - Supports multiple syntaxes: - - None: Empty object schema, expect empty response + A response type is required; ``None`` raises ``TypeError``. Supports + multiple syntaxes: - dict: `{"low": {"title": "..."}}` -> single-select titled enum - list patterns: - `[["a", "b"]]` -> multi-select untitled @@ -150,26 +161,16 @@ def parse_elicit_response_type( The ``response_title`` and ``response_description`` arguments customize the label and description of the wrapped ``value`` property for the scalar/dict/list shorthand forms. They are only valid when FastMCP is wrapping the response - type; passing them with a full BaseModel/dataclass (or ``None``) raises - ``TypeError``, because in those cases the user already controls field - metadata via ``Field(title=..., description=...)``. + type; passing them with a full BaseModel/dataclass raises ``TypeError``, + because in those cases the user already controls field metadata via + ``Field(title=..., description=...)``. """ has_response_metadata = ( response_title is not None or response_description is not None ) if response_type is None: - if has_response_metadata: - raise TypeError( - "response_title and response_description are not supported when " - "response_type is None, because the elicitation schema has no " - "fields to label." - ) - return ElicitConfig( - schema={"type": "object", "properties": {}}, - response_type=None, - is_raw=False, - ) + raise TypeError(_NONE_RESPONSE_TYPE_ERROR) if isinstance(response_type, dict): config = _parse_dict_syntax(response_type) diff --git a/fastmcp_slim/fastmcp/server/middleware/authorization.py b/fastmcp_slim/fastmcp/server/middleware/authorization.py index 2040778e3..42f3cad81 100644 --- a/fastmcp_slim/fastmcp/server/middleware/authorization.py +++ b/fastmcp_slim/fastmcp/server/middleware/authorization.py @@ -29,15 +29,10 @@ from typing import Any import mcp_types as mt -from fastmcp.exceptions import AuthorizationError +from fastmcp.exceptions import AuthorizationError, InsufficientScopeError from fastmcp.prompts.base import Prompt, PromptResult from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.auth.authorization import ( - AuthCheck, - AuthContext, - run_auth_checks, -) from fastmcp.server.dependencies import get_access_token from fastmcp.server.middleware.middleware import ( CallNext, @@ -45,6 +40,13 @@ from fastmcp.server.middleware.middleware import ( MiddlewareContext, ) from fastmcp.tools.base import Tool, ToolResult +from fastmcp.utilities.authorization import ( + AuthCheck, + AuthContext, + run_auth_checks, + run_auth_checks_with_shortfall, + scope_requirements, +) from fastmcp.utilities.versions import VersionSpec logger = logging.getLogger(__name__) @@ -111,6 +113,60 @@ class AuthMiddleware(Middleware): def __init__(self, auth: AuthCheck | list[AuthCheck]) -> None: self.auth = auth + def _chain_shortfall( + self, + own_missing: list[str], + ctx: AuthContext, + server: Any, + ) -> list[str]: + """Widen this middleware's shortfall to cover the rest of the chain. + + Scope requirements are commonly split across several `AuthMiddleware` + instances (the tag-based configuration does exactly this). Raising as + soon as the first one finds a shortfall means the middleware further in + never runs, so a caller told only the outer scope would obtain it, retry, + and be denied for the next — the same non-convergent loop that reporting + a union within one middleware avoids. + + Sibling requirements are read without evaluating them: `scope_requirements` + is a pure comparison against the token and component. Only the layers the + request would actually reach next may contribute, so the walk starts after + this middleware and stops at the first one whose requirements cannot be + read — a layer holding an opaque check is an unverified gate, and anything + at or beyond it might be unreachable for reasons that have nothing to do + with scopes. Disclosing those requirements would leak what sits behind a + policy the request never passed. + + Layers *before* this one are already known to have passed, so they neither + block the walk nor contribute anything. This middleware's own shortfall + always counts: the request demonstrably reached it. + + The result is therefore complete when the reachable chain is scope-only, + and deliberately partial otherwise — never a disclosure past an + unevaluated gate. + """ + middleware = getattr(server, "middleware", None) + if not isinstance(middleware, list): + return own_missing + + position = next( + (i for i, mw in enumerate(middleware) if mw is self), + None, + ) + if position is None: + return own_missing + + missing = set(own_missing) + for mw in middleware[position + 1 :]: + if not isinstance(mw, AuthMiddleware): + continue + sibling = scope_requirements(mw.auth, ctx) + if sibling is None: + # An unverified gate. Nothing at or beyond it may contribute. + break + missing |= set(sibling) + return sorted(missing) + async def on_list_tools( self, context: MiddlewareContext[mt.ListToolsRequest], @@ -180,7 +236,17 @@ class AuthMiddleware(Middleware): # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=tool) - if not await run_auth_checks(self.auth, ctx): + authorized, missing = await run_auth_checks_with_shortfall(self.auth, ctx) + if not authorized: + if missing: + missing = self._chain_shortfall(missing, ctx, fastmcp.fastmcp) + raise InsufficientScopeError( + missing, + message=( + f"Authorization failed for tool '{tool_name}': " + f"insufficient scope (required: {', '.join(missing)})" + ), + ) raise AuthorizationError( f"Authorization failed for tool '{tool_name}': insufficient permissions" ) @@ -258,7 +324,17 @@ class AuthMiddleware(Middleware): # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=component) - if not await run_auth_checks(self.auth, ctx): + authorized, missing = await run_auth_checks_with_shortfall(self.auth, ctx) + if not authorized: + if missing: + missing = self._chain_shortfall(missing, ctx, fastmcp.fastmcp) + raise InsufficientScopeError( + missing, + message=( + f"Authorization failed for resource '{uri}': " + f"insufficient scope (required: {', '.join(missing)})" + ), + ) raise AuthorizationError( f"Authorization failed for resource '{uri}': insufficient permissions" ) @@ -360,7 +436,17 @@ class AuthMiddleware(Middleware): # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=prompt) - if not await run_auth_checks(self.auth, ctx): + authorized, missing = await run_auth_checks_with_shortfall(self.auth, ctx) + if not authorized: + if missing: + missing = self._chain_shortfall(missing, ctx, fastmcp.fastmcp) + raise InsufficientScopeError( + missing, + message=( + f"Authorization failed for prompt '{prompt_name}': " + f"insufficient scope (required: {', '.join(missing)})" + ), + ) raise AuthorizationError( f"Authorization failed for prompt '{prompt_name}': insufficient permissions" ) diff --git a/fastmcp_slim/fastmcp/server/middleware/caching.py b/fastmcp_slim/fastmcp/server/middleware/caching.py index cbf48b849..d470f1b90 100644 --- a/fastmcp_slim/fastmcp/server/middleware/caching.py +++ b/fastmcp_slim/fastmcp/server/middleware/caching.py @@ -18,8 +18,18 @@ from key_value.aio.wrappers.statistics.wrapper import ( from pydantic import Field from typing_extensions import NotRequired, Self, TypeVar, override -from fastmcp.prompts.base import Message, Prompt, PromptResult -from fastmcp.resources.base import Resource, ResourceContent, ResourceResult +from fastmcp.prompts.base import ( + InputRequiredPromptResult, + Message, + Prompt, + PromptResult, +) +from fastmcp.resources.base import ( + InputRequiredResourceResult, + Resource, + ResourceContent, + ResourceResult, +) from fastmcp.server.dependencies import get_access_token from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult @@ -28,6 +38,28 @@ from fastmcp.utilities.types import FastMCPBaseModel logger: Logger = get_logger(name=__name__) + +def _is_continuation_leg(context: MiddlewareContext[Any]) -> bool: + """Whether this request is answering a previous round's ask (SEP-2322). + + A continuation must bypass the cache entirely. Cache keys are built from the + component's identity and arguments alone, so a continuation shares its key + with a fresh call: reading could serve a prior flow's final answer to this + leg, and writing would serve THIS flow's final answer to a later fresh call, + which would then never be asked the questions at all. + + Either signal marks a continuation. A state-only round (one that carried + `request_state` without asking anything) retries with `input_responses` + still `None`. + """ + fastmcp_ctx = context.fastmcp_context + if fastmcp_ctx is None: + return False + return ( + fastmcp_ctx.input_responses is not None or fastmcp_ctx.request_state is not None + ) + + # Constants ONE_HOUR_IN_SECONDS = 3600 FIVE_MINUTES_IN_SECONDS = 300 @@ -413,19 +445,7 @@ class ResponseCachingMiddleware(Middleware): ) is False or not self._matches_tool_cache_settings(tool_name=tool_name): return await call_next(context) - # A multi-round continuation leg (SEP-2322) must bypass the cache - # entirely: the cache key is built from the tool name and arguments - # only, so a continuation shares its key with a fresh call. Reading - # could serve a prior flow's final answer to this leg; writing would - # serve THIS flow's final answer to a later fresh call — which would - # then never be asked the tool's questions. Either signal marks a - # continuation: a state-only round (request_state with no questions) - # retries with input_responses=None but still carries request_state. - fastmcp_ctx = context.fastmcp_context - if fastmcp_ctx is not None and ( - fastmcp_ctx.input_responses is not None - or fastmcp_ctx.request_state is not None - ): + if _is_continuation_leg(context): return await call_next(context) cache_key: str = _make_call_tool_cache_key( @@ -477,6 +497,9 @@ class ResponseCachingMiddleware(Middleware): if self._read_resource_settings.get("enabled") is False: return await call_next(context) + if _is_continuation_leg(context): + return await call_next(context) + cache_key: str = _make_read_resource_cache_key( msg=context.message, auth_key=_get_auth_partition_key() ) @@ -486,6 +509,14 @@ class ResponseCachingMiddleware(Middleware): return cached_value.unwrap() value: ResourceResult = await call_next(context) + + # Never cache a multi-round-trip ask (SEP-2322). An + # InputRequiredResourceResult is a request for client input on this leg, + # not a stable answer, and it carries no contents — wrapping it would + # cache an empty read and the client would never see the question. + if isinstance(value, InputRequiredResourceResult): + return value + cached_value = CacheableResourceResult.wrap(value) await self._read_resource_cache.put( @@ -507,6 +538,9 @@ class ResponseCachingMiddleware(Middleware): if self._get_prompt_settings.get("enabled") is False: return await call_next(context) + if _is_continuation_leg(context): + return await call_next(context) + cache_key: str = _make_get_prompt_cache_key( msg=context.message, auth_key=_get_auth_partition_key() ) @@ -515,6 +549,14 @@ class ResponseCachingMiddleware(Middleware): return cached_value.unwrap() value: PromptResult = await call_next(context) + + # Never cache a multi-round-trip ask (SEP-2322). An + # InputRequiredPromptResult is a request for client input on this leg, + # not a stable answer, and it carries no messages — wrapping it would + # cache an empty prompt and the client would never see the question. + if isinstance(value, InputRequiredPromptResult): + return value + cached_value = CacheablePromptResult.wrap(value) await self._get_prompt_cache.put( diff --git a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py index af85dd08c..5e96e37dc 100644 --- a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py +++ b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py @@ -28,6 +28,8 @@ from fastmcp.exceptions import ( NotFoundError, to_mcp_error, ) +from fastmcp.prompts.base import InputRequiredPromptResult +from fastmcp.resources.base import InputRequiredResourceResult from fastmcp.server.completions import CompletionValues, normalize_completion from fastmcp.server.dependencies import bind_request_context, extract_version_spec from fastmcp.tools.base import InputRequiredToolResult, ToolResult @@ -295,7 +297,7 @@ class MCPOperationsMixin: self: FastMCP, ctx: ServerRequestContext, params: ReadResourceRequestParams, - ) -> mcp_types.ReadResourceResult: + ) -> mcp_types.ReadResourceResult | mcp_types.InputRequiredResult: """Handle MCP 'resources/read' requests.""" with bind_request_context(ctx): uri = params.uri @@ -306,8 +308,12 @@ class MCPOperationsMixin: try: result = await self.read_resource(str(uri), version=version) except (DisabledError, NotFoundError) as e: - raise to_mcp_error( - NotFoundError(f"Resource not found: {str(uri)!r}") + # SEP-2164: echo the requested URI in `data` so a client that + # pipelined several reads can tell which one is missing. + raise MCPError( + code=INVALID_PARAMS, + message=f"Resource not found: {str(uri)!r}", + data={"uri": str(uri)}, ) from e except FastMCPError as e: # Resource-visible errors (ResourceError, ValidationError, ...) @@ -320,13 +326,30 @@ class MCPOperationsMixin: # already happened inside read_resource. raise to_mcp_error(e) from e + if isinstance(result, InputRequiredResourceResult): + # The resource requested client input (SEP-2322). As with tools + # and prompts, the multi-round-trip result type only exists at + # 2026-07-28, so name the era problem on an older connection + # rather than failing as a generic "invalid result". + if ctx.protocol_version not in MODERN_PROTOCOL_VERSIONS: + raise MCPError( + code=INVALID_PARAMS, + message=( + f"Resource {str(uri)!r} returned an InputRequiredResult " + "to request client input, but the multi-round-trip " + "result type (SEP-2322) only exists at MCP 2026-07-28; " + f"this connection negotiated {ctx.protocol_version!r}." + ), + ) + return result.input_required + return result.to_mcp_result(uri) async def _on_get_prompt( self: FastMCP, ctx: ServerRequestContext, params: GetPromptRequestParams, - ) -> mcp_types.GetPromptResult: + ) -> mcp_types.GetPromptResult | mcp_types.InputRequiredResult: """Handle MCP 'prompts/get' requests.""" with bind_request_context(ctx): name = params.name @@ -351,6 +374,23 @@ class MCPOperationsMixin: # Masking already happened inside render_prompt. raise to_mcp_error(e) from e + if isinstance(result, InputRequiredPromptResult): + # The prompt requested client input (SEP-2322). As with tools, + # the multi-round-trip result type only exists at 2026-07-28, so + # name the era problem on an older connection rather than + # failing as a generic "invalid result". + if ctx.protocol_version not in MODERN_PROTOCOL_VERSIONS: + raise MCPError( + code=INVALID_PARAMS, + message=( + f"Prompt {name!r} returned an InputRequiredResult to " + "request client input, but the multi-round-trip result " + "type (SEP-2322) only exists at MCP 2026-07-28; this " + f"connection negotiated {ctx.protocol_version!r}." + ), + ) + return result.input_required + return result.to_mcp_prompt_result() async def _on_set_logging_level( diff --git a/fastmcp_slim/fastmcp/server/providers/__init__.py b/fastmcp_slim/fastmcp/server/providers/__init__.py index fe0c248f9..f138404f2 100644 --- a/fastmcp_slim/fastmcp/server/providers/__init__.py +++ b/fastmcp_slim/fastmcp/server/providers/__init__.py @@ -36,7 +36,6 @@ from fastmcp.server.providers.skills import ( ClaudeSkillsProvider, SkillProvider, SkillsDirectoryProvider, - SkillsProvider, ) if TYPE_CHECKING: @@ -54,7 +53,6 @@ __all__ = [ "ProxyProvider", "SkillProvider", "SkillsDirectoryProvider", - "SkillsProvider", # Backwards compatibility alias for SkillsDirectoryProvider ] diff --git a/fastmcp_slim/fastmcp/server/providers/addressing.py b/fastmcp_slim/fastmcp/server/providers/addressing.py index 71a70a077..7a9b09d4b 100644 --- a/fastmcp_slim/fastmcp/server/providers/addressing.py +++ b/fastmcp_slim/fastmcp/server/providers/addressing.py @@ -13,9 +13,16 @@ app name + tool name. The hash serves two purposes: and ``read_resource`` synthesize these on demand from the tool's meta. The hash is computed at registration time from ``(app_name, tool_name)`` — -both known at that moment — and stored in ``meta["fastmcp"]["_tool_hash"]``. +both known at that moment — and stored in ``meta["fastmcp"]["tool_hash"]``. Deterministic across replicas (same code → same hash), no registry walk needed. + +The key is deliberately public. Keys prefixed with ``_`` inside the +``fastmcp`` meta namespace are stripped at every serialization boundary +(see ``FastMCPComponent.get_meta``) because they hold process-local state +such as enabled/disabled marks. The hash is the opposite: a stable +identity that intermediaries need in order to recognize a tool they are +forwarding, so it must survive the wire. """ from __future__ import annotations @@ -25,6 +32,9 @@ import hashlib #: Length of the hex hash prefix used in URIs and backend-tool names. HASH_LENGTH = 12 +#: Key inside the ``fastmcp`` meta namespace holding a tool's identity hash. +TOOL_HASH_META_KEY = "tool_hash" + def hash_tool(app_name: str, tool_name: str) -> str: """Deterministic hex hash for a tool in an app. diff --git a/fastmcp_slim/fastmcp/server/providers/aggregate.py b/fastmcp_slim/fastmcp/server/providers/aggregate.py index 53928d055..b6ee36ae9 100644 --- a/fastmcp_slim/fastmcp/server/providers/aggregate.py +++ b/fastmcp_slim/fastmcp/server/providers/aggregate.py @@ -25,7 +25,7 @@ from collections.abc import AsyncIterator, Sequence from contextlib import AsyncExitStack, asynccontextmanager from typing import TYPE_CHECKING, Literal, TypeVar -from fastmcp.exceptions import NotFoundError +from fastmcp.exceptions import NotFoundError, ToolError from fastmcp.server.providers.base import Provider from fastmcp.server.transforms import Namespace from fastmcp.utilities.async_utils import gather @@ -221,19 +221,41 @@ class AggregateProvider(Provider): return None async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None: - """Query all child providers for a tool matching a hash.""" + """Query all child providers for a tool matching a hash. + + The hash identifies a tool by app name and registered name, with no + mount-point component, so composing one app into two branches yields + two distinct tools claiming the same identity. That is ambiguous + rather than resolvable: picking either one silently routes a UI's + call into the wrong branch. Raise instead. + + An ambiguity raised by a child is a verdict, not a provider failure, + so it propagates whatever the error strategy is. Swallowing it would + turn a duplicated app into "unknown tool", which sends whoever hits + it looking for a missing registration instead of a duplicate one. + """ results = await gather( (p.get_tool_by_hash(tool_hash, tool_name) for p in self.providers), return_exceptions=True, ) + matches: list[Tool] = [] for r in results: if isinstance(r, BaseException): - if self.provider_error_strategy == "raise": + if isinstance(r, ToolError) or self.provider_error_strategy == "raise": raise r continue if r is not None: - return r - return None + matches.append(r) + + if not matches: + return None + if len(matches) > 1: + raise ToolError( + f"Ambiguous app tool {tool_name!r}: {len(matches)} components share " + f"the identity {tool_hash!r}. The same app is composed more than " + f"once, so this call cannot be routed to a single tool." + ) + return matches[0] # ------------------------------------------------------------------------- # Resources diff --git a/fastmcp_slim/fastmcp/server/providers/base.py b/fastmcp_slim/fastmcp/server/providers/base.py index 959dc9de9..7941039dd 100644 --- a/fastmcp_slim/fastmcp/server/providers/base.py +++ b/fastmcp_slim/fastmcp/server/providers/base.py @@ -214,9 +214,11 @@ class Provider: """Look up an app-visible tool by its deterministic hash. Same recursive-walk semantics as ``get_app_tool`` but matches on - ``meta["fastmcp"]["_tool_hash"]`` instead of the app name tag. + ``meta["fastmcp"]["tool_hash"]`` instead of the app name tag. Used by the dispatcher when receiving hashed backend-tool calls. """ + from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY + tool = await self._get_tool(tool_name) if tool is not None: meta = tool.meta or {} @@ -227,7 +229,7 @@ class Provider: ) if ( isinstance(fastmcp_meta, dict) - and fastmcp_meta.get("_tool_hash") == tool_hash + and fastmcp_meta.get(TOOL_HASH_META_KEY) == tool_hash and "app" in visibility ): return tool diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py index 53e4a7080..d0816325d 100644 --- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py +++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py @@ -15,7 +15,7 @@ import mcp_types from fastmcp.prompts.base import Prompt from fastmcp.prompts.function_prompt import FunctionPrompt -from fastmcp.server.auth.authorization import AuthCheck +from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.types import AnyFunction if TYPE_CHECKING: diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py index 477833c11..f1f7b106b 100644 --- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py +++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py @@ -20,7 +20,7 @@ from fastmcp.resources.security import ( ResourceSecurity, ) from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.auth.authorization import AuthCheck +from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.types import AnyFunction if TYPE_CHECKING: diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py index cc82b7dec..77081165a 100644 --- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py @@ -25,9 +25,9 @@ from typing import ( import mcp_types from mcp_types import ToolAnnotations -from fastmcp.server.auth.authorization import AuthCheck from fastmcp.tools.base import Tool from fastmcp.tools.function_tool import FunctionTool +from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import AnyFunction, NotSet, NotSetT diff --git a/fastmcp_slim/fastmcp/server/providers/prefab_payload.py b/fastmcp_slim/fastmcp/server/providers/prefab_payload.py new file mode 100644 index 000000000..9741954d1 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/providers/prefab_payload.py @@ -0,0 +1,158 @@ +"""Late-bound tool names in Prefab UI payloads. + +A Prefab UI is serialized during the entry tool's call, deep inside whatever +composition the server happens to have. At that moment nothing knows what the +backend tools will be *called* by the time the payload reaches a host: every +layer above may rename them, and the outermost layer's names are the only ones +a client can actually invoke. + +So the payload leaves the app addressed by identity — ``_``, +stable everywhere — and every FastMCP server rewrites those references on the +way out to whatever it lists that tool as. Servers rewrite innermost-first, so +the edge writes last and wins. + +Rewriting a name in place would destroy the identity for the next layer up, so +the payload carries a name-to-identity map under ``_meta.fastmcp.toolNames``. +Each layer resolves through the map and updates it. The action objects keep the +exact shape ``prefab_ui`` defines — only the value of ``tool`` changes, and only +ever to another valid tool name. + +Renderers read ``_meta`` already and ignore keys they don't recognize, so this +needs no renderer change. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from fastmcp.server.providers.addressing import parse_hashed_backend_name + +#: Action discriminator emitted by ``prefab_ui``'s ``CallTool``. +_TOOL_CALL_ACTION = "toolCall" + +_META_KEY = "_meta" +_FASTMCP_KEY = "fastmcp" +_TOOL_NAMES_KEY = "toolNames" + +#: Resolves an identity hash to the name this server lists that tool under. +#: Returns None when the identity cannot be resolved here, in which case the +#: existing reference is left alone. +IdentityResolver = Callable[[str], str | None] + + +def _walk_tool_calls(node: Any) -> list[dict[str, Any]]: + """Collect every ``toolCall`` action object in a payload tree.""" + found: list[dict[str, Any]] = [] + if isinstance(node, dict): + if node.get("action") == _TOOL_CALL_ACTION and isinstance( + node.get("tool"), str + ): + found.append(node) + for value in node.values(): + found.extend(_walk_tool_calls(value)) + elif isinstance(node, list): + for item in node: + found.extend(_walk_tool_calls(item)) + return found + + +def _read_map(payload: dict[str, Any]) -> dict[str, str]: + meta = payload.get(_META_KEY) + if not isinstance(meta, dict): + return {} + fastmcp_meta = meta.get(_FASTMCP_KEY) + if not isinstance(fastmcp_meta, dict): + return {} + names = fastmcp_meta.get(_TOOL_NAMES_KEY) + if not isinstance(names, dict): + return {} + return {k: v for k, v in names.items() if isinstance(k, str) and isinstance(v, str)} + + +def _write_map(payload: dict[str, Any], names: dict[str, str]) -> None: + meta = payload.setdefault(_META_KEY, {}) + if not isinstance(meta, dict): + return + fastmcp_meta = meta.setdefault(_FASTMCP_KEY, {}) + if not isinstance(fastmcp_meta, dict): + return + fastmcp_meta[_TOOL_NAMES_KEY] = names + + +def payload_has_identities(payload: Any) -> bool: + """Cheap guard: does this payload carry tool references worth rewriting? + + Runs on every tool result, so it must not walk the tree. + """ + return isinstance(payload, dict) and bool(_read_map(payload)) + + +def annotate_payload_identities(payload: dict[str, Any]) -> dict[str, Any]: + """Record the identity-addressed form of each reference, at serialization. + + References start out as ``_``, so the map begins as an + identity map to itself. Once a later layer rewrites a name, this is the + only remaining route back: it carries both what the reference points at + and the address any server can fall back to. + """ + if not isinstance(payload, dict): + return payload + + addresses: dict[str, str] = dict(_read_map(payload)) + for action in _walk_tool_calls(payload): + tool_name = action["tool"] + if tool_name in addresses: + continue + if parse_hashed_backend_name(tool_name) is not None: + addresses[tool_name] = tool_name + + if addresses: + _write_map(payload, addresses) + return payload + + +def rewrite_payload_tool_names( + payload: Any, + resolve: IdentityResolver, +) -> Any: + """Re-address a payload's tool references to this server's own names. + + Mutates in place and returns the payload. + + A reference this server cannot resolve is restored to its + identity-addressed form rather than left as-is. Leaving it would strand + whatever name an inner server chose — a name that is correct there and + meaningless here — and, unlike the identity form, a stranded name has no + route back. Restoring keeps the reference resolvable by the dispatcher, + or by any server further out with a better view. + """ + if not isinstance(payload, dict): + return payload + + addresses = _read_map(payload) + if not addresses: + return payload + + rebound: dict[str, str] = {} + for current_name, address in addresses.items(): + parsed = parse_hashed_backend_name(address) + new_name = resolve(parsed[0]) if parsed is not None else None + if new_name is None: + new_name = address + if new_name != current_name: + rebound[current_name] = new_name + + if not rebound: + return payload + + for action in _walk_tool_calls(payload): + new_name = rebound.get(action["tool"]) + if new_name is not None: + action["tool"] = new_name + + _write_map( + payload, + {rebound.get(name, name): address for name, address in addresses.items()}, + ) + return payload diff --git a/fastmcp_slim/fastmcp/server/providers/prefab_synthesis.py b/fastmcp_slim/fastmcp/server/providers/prefab_synthesis.py index e47bd0391..111cd677e 100644 --- a/fastmcp_slim/fastmcp/server/providers/prefab_synthesis.py +++ b/fastmcp_slim/fastmcp/server/providers/prefab_synthesis.py @@ -2,7 +2,7 @@ Tools marked as Prefab (via ``app=True``, ``PrefabAppConfig``, etc.) carry a placeholder ``meta.ui.resourceUri`` and optionally a hash in -``meta.fastmcp._tool_hash``. This module synthesizes per-tool renderer +``meta.fastmcp.tool_hash``. This module synthesizes per-tool renderer resources on demand at ``list_resources`` and ``read_resource`` time without storing or materializing anything. @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any, cast from fastmcp.server.providers.addressing import ( HASH_LENGTH, + TOOL_HASH_META_KEY, hash_tool, parse_hashed_resource_uri, ) @@ -48,7 +49,7 @@ def _get_tool_hash(tool: Tool) -> str | None: meta = tool.meta or {} fastmcp_meta = meta.get("fastmcp") if isinstance(fastmcp_meta, dict): - h = fastmcp_meta.get("_tool_hash") + h = fastmcp_meta.get(TOOL_HASH_META_KEY) if isinstance(h, str) and len(h) == HASH_LENGTH: return h # Fall back to computing from app name diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index 2011e160a..9b1cd4a69 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -21,6 +21,7 @@ from mcp import ClientSession from mcp.server.connection import Connection from mcp.server.context import ServerRequestContext from mcp.shared.exceptions import MCPError +from mcp.shared.inbound import x_mcp_header_map from mcp_types import ( METHOD_NOT_FOUND, BlobResourceContents, @@ -30,7 +31,7 @@ from mcp_types import ( from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic.networks import AnyUrl -from fastmcp.client.client import Client, SDKServer +from fastmcp.client.client import Client, SDKServer, _connection_failure from fastmcp.client.elicitation import ElicitResult, create_elicitation_callback from fastmcp.client.logging import LogMessage, create_log_callback from fastmcp.client.roots import RootsList, create_roots_callback @@ -38,12 +39,16 @@ from fastmcp.client.sampling import create_sampling_callback from fastmcp.client.telemetry import client_span from fastmcp.client.transports import ClientTransportT from fastmcp.client.transports.base import TransportOptions -from fastmcp.exceptions import ResourceError +from fastmcp.exceptions import ResourceError, ToolError from fastmcp.mcp_config import MCPConfig from fastmcp.prompts import Message, Prompt, PromptResult -from fastmcp.prompts.base import PromptArgument +from fastmcp.prompts.base import InputRequiredPromptResult, PromptArgument from fastmcp.resources import Resource, ResourceTemplate -from fastmcp.resources.base import ResourceContent, ResourceResult +from fastmcp.resources.base import ( + InputRequiredResourceResult, + ResourceContent, + ResourceResult, +) from fastmcp.resources.template import expand_uri_template from fastmcp.server.context import Context from fastmcp.server.dependencies import fastmcp_request_ctx, get_context @@ -111,12 +116,52 @@ _PROXY_TRANSPORT_ERRORS: tuple[type[Exception], ...] = ( def _proxy_upstream_error(error: Exception) -> MCPError: + """Report an unreachable backend the same way however the failure arrived. + + Depending on where the dead connection is noticed, the proxy sees either + FastMCP's own `RuntimeError("Client failed to connect: ...")` or the raw + transport error underneath it. Both describe one thing — the proxy could + not reach its upstream — so both are presented identically rather than + leaking the race into the message the front client reads. + """ return MCPError( code=mcp_types.INTERNAL_ERROR, - message=str(error), + message=str(_connection_failure(error)), ) +async def _relay_read_resource( + client: Client, uri: str, ctx: Context | None +) -> ( + list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents] + | mcp_types.InputRequiredResult +): + """Read a backend resource, surfacing a guard ask rather than driving it. + + Mirrors `ProxyTool.run`: on a modern backend the low-level session is used + so an `InputRequiredResult` (SEP-2322) comes back as a result for the parent + to forward, instead of the high-level client trying to answer it here — the + proxy has no back-channel to the real user, so driving it fails outright. + The inbound request's continuation state travels down so the backend guard + sees the client's answers on its own `ctx.input_responses`. Trace context + still propagates: the SDK's JSON-RPC dispatcher injects it on every outgoing + request (SEP-414), below whichever client layer issued the call. + """ + if client.protocol_version not in MODERN_PROTOCOL_VERSIONS: + return await client.read_resource(uri) + result = await client._await_with_session_monitoring( + client.session.read_resource( + uri, + input_responses=ctx.input_responses if ctx else None, + request_state=ctx.request_state if ctx else None, + allow_input_required=True, + ) + ) + if isinstance(result, mcp_types.InputRequiredResult): + return result + return list(result.contents) + + def _stash_proxy_request_context(client: Client, ctx: Context) -> None: """Stash the proxy's ``RequestContext`` on a ``ProxyClient`` before a backend call. @@ -289,6 +334,18 @@ class ProxyTool(Tool): "mcp_types.RequestParamsMeta | None", inject_trace_context(meta) or None, ) + # SEP-2243: a modern backend rejects a `tools/call` whose + # `x-mcp-header` argument is not mirrored into an `Mcp-Param-*` + # header. The SDK client emits those headers only for tools it + # has listed (it caches the annotation map on `list_tools`), + # but a proxied call goes straight to `call_tool` on a fresh + # session. Seed the session's map from the backend tool's + # advertised schema so the header is emitted and the call is + # accepted; an unannotated schema yields an empty map and no + # headers, matching the client's own behavior. + header_map = x_mcp_header_map(self.parameters) + if header_map: + client.session._x_mcp_header_maps[backend_name] = header_map result = await client._await_with_session_monitoring( client.session.call_tool( name=backend_name, @@ -405,9 +462,12 @@ class ProxyResource(Resource): ) as span: span.set_attribute("fastmcp.provider.type", "ProxyProvider") client = await self._get_client() + ctx = get_context() async with client: - _stash_proxy_request_context(client, get_context()) - result = await client.read_resource(backend_uri) + _stash_proxy_request_context(client, ctx) + result = await _relay_read_resource(client, backend_uri, ctx) + if isinstance(result, mcp_types.InputRequiredResult): + return InputRequiredResourceResult(result) if not result: raise ResourceError( f"Remote server returned empty content for {backend_uri}" @@ -503,9 +563,28 @@ class ProxyTemplate(ResourceTemplate): backend_template = self._backend_uri_template or self.uri_template parameterized_uri = expand_uri_template(backend_template, params) client = await self._get_client() + ctx = context or get_context() async with client: - _stash_proxy_request_context(client, context or get_context()) - result = await client.read_resource(parameterized_uri) + _stash_proxy_request_context(client, ctx) + result = await _relay_read_resource(client, parameterized_uri, ctx) + + if isinstance(result, mcp_types.InputRequiredResult): + # The backend template asked for input. `InputRequiredResourceResult` + # is a `ResourceResult`, so caching it on the returned resource lets + # the ask ride the ordinary read path out to the parent's wire + # handler, which unwraps it. + return ProxyResource( + client_factory=self._client_factory, + uri=parameterized_uri, + name=self.name, + title=self.title, + description=self.description, + mime_type=self.mime_type or "text/plain", + icons=self.icons, + meta=self.meta, + tags=get_fastmcp_metadata(self.meta).get("tags", []), + _cached_content=InputRequiredResourceResult(result), + ) if not result: raise ResourceError( @@ -622,9 +701,26 @@ class ProxyPrompt(Prompt): ) as span: span.set_attribute("fastmcp.provider.type", "ProxyProvider") client = await self._get_client() + ctx = get_context() async with client: - _stash_proxy_request_context(client, get_context()) - result = await client.get_prompt(backend_name, arguments) + _stash_proxy_request_context(client, ctx) + if client.protocol_version in MODERN_PROTOCOL_VERSIONS: + # See `_relay_read_resource`: surface a backend guard's ask + # instead of trying to answer it inside the proxy. + raw = await client._await_with_session_monitoring( + client.session.get_prompt( + backend_name, + arguments, + input_responses=ctx.input_responses if ctx else None, + request_state=ctx.request_state if ctx else None, + allow_input_required=True, + ) + ) + if isinstance(raw, mcp_types.InputRequiredResult): + return InputRequiredPromptResult(raw) + result = raw + else: + result = await client.get_prompt(backend_name, arguments) # Convert GetPromptResult to PromptResult, preserving meta from result # (not the static prompt meta which includes fastmcp tags) # Convert PromptMessages to Messages @@ -695,8 +791,8 @@ class ProxyProvider(Provider): mcp = FastMCP("Proxy Server") mcp.add_provider(proxy) - # Can also add with namespace - mcp.add_provider(proxy.with_namespace("remote")) + # Can also add with a namespace + mcp.add_provider(proxy, namespace="remote") ``` """ @@ -768,6 +864,54 @@ class ProxyProvider(Provider): return None return max(matching, key=version_sort_key) + async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None: + """Resolve an identity against the remote listing. + + The base implementation looks the tool up by its registered name, + which assumes the name survived to here. Across a proxy it need not: + a backend that mounts its app under a namespace advertises + ``crm_save``, and nothing named ``save`` was ever listed. Matching on + the identity carried in meta is what the identity is for. + + A remote that mounts one app twice sends back two tools claiming one + identity, exactly as a local composition would. That is refused here + on the same terms ``AggregateProvider`` refuses it, so a duplicated + app is caught wherever it is composed rather than only nearby. + """ + from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY + + cache = self._tools_cache + if cache is None or not cache.is_fresh(self._cache_ttl): + await self._list_tools() + cache = self._tools_cache + assert cache is not None + + matches: list[Tool] = [] + for tool in cache.items: + meta = tool.meta or {} + fastmcp_meta = meta.get("fastmcp") + ui_meta = meta.get("ui") + visibility = ( + ui_meta.get("visibility", []) if isinstance(ui_meta, dict) else [] + ) + if ( + isinstance(fastmcp_meta, dict) + and fastmcp_meta.get(TOOL_HASH_META_KEY) == tool_hash + and "app" in visibility + ): + matches.append(tool) + + if not matches: + return None + distinct = {tool.name for tool in matches} + if len(distinct) > 1: + raise ToolError( + f"Ambiguous app tool {tool_name!r}: {len(distinct)} components share " + f"the identity {tool_hash!r}. The same app is composed more than " + f"once, so this call cannot be routed to a single tool." + ) + return max(matches, key=version_sort_key) + # ------------------------------------------------------------------------- # Resource methods # ------------------------------------------------------------------------- @@ -1175,10 +1319,20 @@ class FastMCPProxy(FastMCP): try: async with client: result.instructions = client.session.instructions - except MCPError: - raise - except _PROXY_TRANSPORT_ERRORS as error: - raise _proxy_upstream_error(error) from error + except (MCPError, *_PROXY_TRANSPORT_ERRORS) as error: + # Instructions are optional metadata, so an unreachable backend + # must not fail negotiation itself. Failing here would surface + # as a confusing protocol error: the client's auto-negotiation + # reads any `server/discover` error as "not a modern server" + # and retries with the initialize handshake, which this + # modern-serving proxy then rejects — hiding the real cause. + # Answer without upstream instructions instead and let the + # backend failure surface on the first real operation, where + # the proxy reports it as an upstream connection error. + logger.debug( + "Could not read upstream instructions for server/discover: %r", + error, + ) return result self._mcp_server.add_request_handler( @@ -1194,9 +1348,19 @@ class FastMCPProxy(FastMCP): async def default_proxy_roots_handler( context: ServerRequestContext[Any, Any], ) -> RootsList: - """Forward list roots request from remote server to proxy's connected clients.""" + """Forward list roots request from remote server to proxy's connected clients. + + A handshake-era backend can still issue `roots/list`, and the proxy is that + backend's client, so it relays the request onto its own front session. This + reaches the wire through the SDK session rather than a `Context` method: + `ctx.list_roots()` is not part of FastMCP's server-authoring API, because + SEP-2577 removed server-initiated requests from the modern protocol. The + relay exists only for handshake-era interop on both legs. + """ ctx = get_context() - return await ctx.list_roots() + # Deprecated upstream in SDK v2; the handshake-era relay is the one caller. + result = await ctx.session.list_roots() # ty: ignore[deprecated] + return result.roots async def default_proxy_sampling_handler( @@ -1204,16 +1368,27 @@ async def default_proxy_sampling_handler( params: mcp_types.CreateMessageRequestParams, context: ServerRequestContext[Any, Any], ) -> mcp_types.CreateMessageResult: - """Forward sampling request from remote server to proxy's connected clients.""" + """Forward sampling request from remote server to proxy's connected clients. + + Relays through the SDK session for the same reason as + `default_proxy_roots_handler`: server-initiated sampling is not part of + FastMCP's server-authoring API, and this path only ever runs when both legs + of the proxy speak the handshake era. + """ ctx = get_context() - result = await ctx.sample( - list(messages), + # Deprecated upstream in SDK v2; the handshake-era relay is the one caller. + result = await ctx.session.create_message( # ty: ignore[deprecated] + messages=list(messages), system_prompt=params.system_prompt, temperature=params.temperature, max_tokens=params.max_tokens, model_preferences=params.model_preferences, + related_request_id=ctx.origin_request_id, ) - content = mcp_types.TextContent(type="text", text=result.text or "") + text = ( + result.content.text if isinstance(result.content, mcp_types.TextContent) else "" + ) + content = mcp_types.TextContent(type="text", text=text) return mcp_types.CreateMessageResult( role="assistant", model="fastmcp-client", @@ -1314,12 +1489,7 @@ def _restore_request_context( def _make_restoring_handler(handler: Callable, rc_ref: list[Any]) -> Callable: - """Wrap a proxy handler to restore request_ctx before delegating. - - The wrapper is a plain ``async def`` so it passes - ``inspect.isfunction()`` checks in handler registration paths - (e.g., ``create_roots_callback``). - """ + """Wrap a proxy handler to restore request_ctx before delegating.""" async def wrapper(*args: Any, **kwargs: Any) -> Any: _restore_request_context(rc_ref) diff --git a/fastmcp_slim/fastmcp/server/providers/skills/__init__.py b/fastmcp_slim/fastmcp/server/providers/skills/__init__.py index b15c1c636..5945944b5 100644 --- a/fastmcp_slim/fastmcp/server/providers/skills/__init__.py +++ b/fastmcp_slim/fastmcp/server/providers/skills/__init__.py @@ -40,10 +40,6 @@ from fastmcp.server.providers.skills.vendor_providers import ( ) -# Backwards compatibility alias -SkillsProvider = SkillsDirectoryProvider - - __all__ = [ "ClaudeSkillsProvider", "CodexSkillsProvider", @@ -54,6 +50,5 @@ __all__ = [ "OpenCodeSkillsProvider", "SkillProvider", "SkillsDirectoryProvider", - "SkillsProvider", # Backwards compatibility alias "VSCodeSkillsProvider", ] diff --git a/fastmcp_slim/fastmcp/server/sampling/__init__.py b/fastmcp_slim/fastmcp/server/sampling/__init__.py deleted file mode 100644 index 392326d35..000000000 --- a/fastmcp_slim/fastmcp/server/sampling/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Sampling module for FastMCP servers.""" - -from fastmcp.server.sampling.run import SampleStep, SamplingResult -from fastmcp.server.sampling.sampling_tool import SamplingTool - -__all__ = [ - "SampleStep", - "SamplingResult", - "SamplingTool", -] diff --git a/fastmcp_slim/fastmcp/server/sampling/run.py b/fastmcp_slim/fastmcp/server/sampling/run.py deleted file mode 100644 index ed959ed60..000000000 --- a/fastmcp_slim/fastmcp/server/sampling/run.py +++ /dev/null @@ -1,836 +0,0 @@ -"""Sampling types and helper functions for FastMCP servers.""" - -from __future__ import annotations - -import inspect -import json -from collections.abc import Callable, Sequence -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Generic, Literal, cast - -import anyio -from mcp_types import ( - ClientCapabilities, - CreateMessageResult, - CreateMessageResultWithTools, - ModelHint, - ModelPreferences, - SamplingCapability, - SamplingMessage, - SamplingMessageContentBlock, - SamplingToolsCapability, - TextContent, - ToolChoice, - ToolResultContent, - ToolUseContent, -) -from mcp_types import CreateMessageRequestParams as SamplingParams -from mcp_types import Tool as SDKTool -from opentelemetry.trace import SpanKind, Status, StatusCode -from pydantic import ValidationError -from typing_extensions import TypeVar - -from fastmcp import settings -from fastmcp.exceptions import ToolError -from fastmcp.server.sampling.sampling_tool import SamplingTool -from fastmcp.telemetry import get_tracer, restore_dropped_attributes -from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool_transform import TransformedTool -from fastmcp.utilities.async_utils import gather -from fastmcp.utilities.json_schema import compress_schema -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import get_cached_typeadapter - -logger = get_logger(__name__) - -if TYPE_CHECKING: - from fastmcp.server.context import Context - -ResultT = TypeVar("ResultT") - -# Maximum number of consecutive final_response validation retries (not -# counting the initial attempt) before aborting. Total attempts = N + 1. -_MAX_VALIDATION_RETRIES = 3 - -# Simplified tool choice type - just the mode string instead of the full MCP object -ToolChoiceOption = Literal["auto", "required", "none"] - -# How many times we retry when the LLM returns text instead of calling final_response -_MAX_TEXT_RESPONSE_RETRIES = 3 - - -@dataclass -class SamplingResult(Generic[ResultT]): - """Result of a sampling operation. - - Attributes: - text: The text representation of the result (raw text or JSON for structured). - result: The typed result (str for text, parsed object for structured output). - history: All messages exchanged during sampling. - """ - - text: str | None - result: ResultT - history: list[SamplingMessage] - - -@dataclass -class SampleStep: - """Result of a single sampling call. - - Represents what the LLM returned in this step plus the message history. - """ - - response: CreateMessageResult | CreateMessageResultWithTools - history: list[SamplingMessage] - - @property - def is_tool_use(self) -> bool: - """True if the LLM is requesting tool execution.""" - if isinstance(self.response, CreateMessageResultWithTools): - return self.response.stop_reason == "toolUse" - return False - - @property - def text(self) -> str | None: - """Extract text from the response, if available.""" - content = self.response.content - if isinstance(content, list): - for block in content: - if isinstance(block, TextContent): - return block.text - return None - elif isinstance(content, TextContent): - return content.text - return None - - @property - def tool_calls(self) -> list[ToolUseContent]: - """Get the list of tool calls from the response.""" - content = self.response.content - if isinstance(content, list): - return [c for c in content if isinstance(c, ToolUseContent)] - elif isinstance(content, ToolUseContent): - return [content] - return [] - - -def _parse_model_preferences( - model_preferences: ModelPreferences | str | list[str] | None, -) -> ModelPreferences | None: - """Convert model preferences to ModelPreferences object.""" - if model_preferences is None: - return None - elif isinstance(model_preferences, ModelPreferences): - return model_preferences - elif isinstance(model_preferences, str): - return ModelPreferences(hints=[ModelHint(name=model_preferences)]) - elif isinstance(model_preferences, list): - if not all(isinstance(h, str) for h in model_preferences): - raise ValueError("All elements of model_preferences list must be strings.") - return ModelPreferences(hints=[ModelHint(name=h) for h in model_preferences]) - else: - raise ValueError( - "model_preferences must be one of: ModelPreferences, str, list[str], or None." - ) - - -# --- Standalone functions for sample_step() --- - - -def determine_handler_mode( - context: Context, needs_tools: bool, *, client_available: bool = True -) -> bool: - """Determine whether to use fallback handler or client for sampling. - - Args: - context: The MCP context. - needs_tools: Whether the sampling request requires tool support. - client_available: Whether the client back-channel can be reached at all. - On modern (2026-07-28) connections the server-initiated createMessage - back-channel was removed (SEP-2577), so the client can never serve a - sampling request; pass False there to force the server-side handler - path (``"fallback"`` behaves like ``"always"`` when a handler exists). - - Returns: - True if fallback handler should be used, False to use client. - - Raises: - ValueError: If client lacks required capability and no fallback configured. - """ - fastmcp = context.fastmcp - session = context.session - - # Check what capabilities the client has. On connections without a - # back-channel the client can never serve the request regardless of the - # capabilities it advertised, so treat both as unavailable. - has_sampling = client_available and session.check_client_capability( - capability=ClientCapabilities(sampling=SamplingCapability()) - ) - has_tools_capability = client_available and session.check_client_capability( - capability=ClientCapabilities( - sampling=SamplingCapability(tools=SamplingToolsCapability()) - ) - ) - - if fastmcp.sampling_handler_behavior == "always": - if fastmcp.sampling_handler is None: - raise ValueError( - "sampling_handler_behavior is 'always' but no handler configured" - ) - return True - elif fastmcp.sampling_handler_behavior == "fallback": - client_sufficient = has_sampling and (not needs_tools or has_tools_capability) - if not client_sufficient: - if fastmcp.sampling_handler is None: - if needs_tools and has_sampling and not has_tools_capability: - raise ValueError( - "Client does not support sampling with tools. " - "The client must advertise the sampling.tools capability." - ) - raise ValueError("Client does not support sampling") - return True - elif fastmcp.sampling_handler_behavior is not None: - raise ValueError( - f"Invalid sampling_handler_behavior: {fastmcp.sampling_handler_behavior!r}. " - "Must be 'always', 'fallback', or None." - ) - elif not has_sampling: - raise ValueError("Client does not support sampling") - elif needs_tools and not has_tools_capability: - raise ValueError( - "Client does not support sampling with tools. " - "The client must advertise the sampling.tools capability." - ) - - return False - - -async def call_sampling_handler( - context: Context, - messages: list[SamplingMessage], - *, - system_prompt: str | None, - temperature: float | None, - max_tokens: int, - model_preferences: ModelPreferences | str | list[str] | None, - sdk_tools: list[SDKTool] | None, - tool_choice: ToolChoice | None, -) -> CreateMessageResult | CreateMessageResultWithTools: - """Make LLM call using the fallback handler. - - Note: This function expects the caller (sample_step) to have validated that - sampling_handler is set via determine_handler_mode(). The checks below are - safeguards against internal misuse. - """ - if context.fastmcp.sampling_handler is None: - raise RuntimeError("sampling_handler is None") - if context.request_context is None: - raise RuntimeError("request_context is None") - - result = context.fastmcp.sampling_handler( - messages, - SamplingParams( - system_prompt=system_prompt, - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - model_preferences=_parse_model_preferences(model_preferences), - tools=sdk_tools, - tool_choice=tool_choice, - ), - # SamplingHandler is typed against the SDK's RequestContext placeholder, - # but FastMCP hands handlers its own FastMCPRequestContext wrapper at - # runtime; the two aren't structurally related in the type system. - context.request_context, # ty: ignore[invalid-argument-type] - ) - - if inspect.isawaitable(result): - result = await result - - result = cast("str | CreateMessageResult | CreateMessageResultWithTools", result) - - # Convert string to CreateMessageResult - if isinstance(result, str): - return CreateMessageResult( - role="assistant", - content=TextContent(type="text", text=result), - model="unknown", - stop_reason="endTurn", - ) - - return result - - -async def execute_tools( - tool_calls: list[ToolUseContent], - tool_map: dict[str, SamplingTool], - mask_error_details: bool = False, - tool_concurrency: int | None = None, -) -> list[ToolResultContent]: - """Execute tool calls and return results. - - Args: - tool_calls: List of tool use requests from the LLM. - tool_map: Mapping from tool name to SamplingTool. - mask_error_details: If True, mask detailed error messages from tool execution. - When masked, only generic error messages are returned to the LLM. - Tools can explicitly raise ToolError to bypass masking when they want - to provide specific error messages to the LLM. - tool_concurrency: Controls parallel execution of tools: - - None (default): Sequential execution (one at a time) - - 0: Unlimited parallel execution - - N > 0: Execute at most N tools concurrently - If any tool has sequential=True, all tools execute sequentially - regardless of this setting. - - Returns: - List of tool result content blocks in the same order as tool_calls. - """ - if tool_concurrency is not None and tool_concurrency < 0: - raise ValueError( - f"tool_concurrency must be None, 0 (unlimited), or a positive integer, " - f"got {tool_concurrency}" - ) - - async def _execute_single_tool(tool_use: ToolUseContent) -> ToolResultContent: - """Execute a single tool and return its result.""" - tool = tool_map.get(tool_use.name) - if tool is None: - return ToolResultContent( - type="tool_result", - tool_use_id=tool_use.id, - content=[ - TextContent( - type="text", - text=f"Error: Unknown tool '{tool_use.name}'", - ) - ], - is_error=True, - ) - - tracer = get_tracer() - span_attrs = { - "gen_ai.tool.name": tool_use.name, - "fastmcp.tool.use_id": tool_use.id, - } - with tracer.start_as_current_span( - f"sampling tool {tool_use.name}", - kind=SpanKind.INTERNAL, - attributes=span_attrs, - ) as span: - # Restore: `attributes=span_attrs` above lets on_start hooks and - # the sampler see these values at creation time. But OTel's - # Tracer.start_span builds the span from - # `sampling_result.attributes`, not the `attributes` kwarg - # directly — a custom Sampler whose SamplingResult.attributes - # defaults to None silently drops everything we passed. This - # only fires when the span ends up with no attributes at all, so - # any sampler that supplied attributes of its own — forwarding - # ours, redacting or replacing some, or substituting entirely its - # own — is left untouched, as is an SDK attribute limit that - # evicted some. - if span.is_recording(): - restore_dropped_attributes(span, span_attrs) - try: - result_value = await tool.run(tool_use.input) - return ToolResultContent( - type="tool_result", - tool_use_id=tool_use.id, - content=[TextContent(type="text", text=str(result_value))], - ) - except ToolError as e: - if span.is_recording(): - span.set_attribute("error.type", "tool_error") - span.record_exception(e) - span.set_status(Status(StatusCode.ERROR, str(e))) - logger.log( - e.log_level, - f"Error calling sampling tool '{tool_use.name}'", - exc_info=True, - ) - return ToolResultContent( - type="tool_result", - tool_use_id=tool_use.id, - content=[TextContent(type="text", text=str(e))], - is_error=True, - ) - except Exception as e: - if span.is_recording(): - span.set_attribute("error.type", type(e).__qualname__) - span.record_exception(e) - span.set_status(Status(StatusCode.ERROR, str(e))) - logger.exception(f"Error calling sampling tool '{tool_use.name}'") - if mask_error_details: - error_text = f"Error executing tool '{tool_use.name}'" - else: - error_text = f"Error executing tool '{tool_use.name}': {e}" - return ToolResultContent( - type="tool_result", - tool_use_id=tool_use.id, - content=[TextContent(type="text", text=error_text)], - is_error=True, - ) - - # Check if any tool requires sequential execution - requires_sequential = any( - tool.sequential - for tool_use in tool_calls - if (tool := tool_map.get(tool_use.name)) is not None - ) - - # Execute sequentially if required or if concurrency is None (default) - if tool_concurrency is None or requires_sequential: - tool_results: list[ToolResultContent] = [] - for tool_use in tool_calls: - result = await _execute_single_tool(tool_use) - tool_results.append(result) - return tool_results - - # Execute in parallel - if tool_concurrency == 0: - # Unlimited parallel execution - return await gather(_execute_single_tool(tc) for tc in tool_calls) - else: - # Bounded parallel execution with semaphore - semaphore = anyio.Semaphore(tool_concurrency) - - async def bounded_execute(tool_use: ToolUseContent) -> ToolResultContent: - async with semaphore: - return await _execute_single_tool(tool_use) - - return await gather(bounded_execute(tc) for tc in tool_calls) - - -# --- Helper functions for sampling --- - - -def prepare_messages( - messages: str | Sequence[str | SamplingMessage], -) -> list[SamplingMessage]: - """Convert various message formats to a list of SamplingMessage objects.""" - if isinstance(messages, str): - return [ - SamplingMessage( - content=TextContent(text=messages, type="text"), role="user" - ) - ] - else: - return [ - SamplingMessage(content=TextContent(text=m, type="text"), role="user") - if isinstance(m, str) - else m - for m in messages - ] - - -def prepare_tools( - tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] - | None, -) -> list[SamplingTool] | None: - """Convert tools to SamplingTool objects. - - Accepts SamplingTool instances, FunctionTool instances, TransformedTool instances, - or plain callable functions. FunctionTool and TransformedTool are converted using - from_callable_tool(), while plain functions use from_function(). - - Args: - tools: Sequence of tools to prepare. Can be SamplingTool, FunctionTool, - TransformedTool, or plain callable functions. - - Returns: - List of SamplingTool instances, or None if tools is None. - """ - if tools is None: - return None - - sampling_tools: list[SamplingTool] = [] - for t in tools: - if isinstance(t, SamplingTool): - sampling_tools.append(t) - elif isinstance(t, (FunctionTool, TransformedTool)): - sampling_tools.append(SamplingTool.from_callable_tool(t)) - elif callable(t): - sampling_tools.append(SamplingTool.from_function(t)) - else: - raise TypeError( - f"Expected SamplingTool, FunctionTool, TransformedTool, or callable, got {type(t)}" - ) - - return sampling_tools if sampling_tools else None - - -def extract_tool_calls( - response: CreateMessageResult | CreateMessageResultWithTools, -) -> list[ToolUseContent]: - """Extract tool calls from a response.""" - content = response.content - if isinstance(content, list): - return [c for c in content if isinstance(c, ToolUseContent)] - elif isinstance(content, ToolUseContent): - return [content] - return [] - - -def create_final_response_tool(result_type: type) -> SamplingTool: - """Create a synthetic 'final_response' tool for structured output. - - This tool is used to capture structured responses from the LLM. - The tool's schema is derived from the result_type. - """ - type_adapter = get_cached_typeadapter(result_type) - schema = type_adapter.json_schema() - schema = compress_schema(schema, prune_titles=True) - - # Tool parameters must be object-shaped. Wrap primitives in {"value": } - if schema.get("type") != "object": - schema = { - "type": "object", - "properties": {"value": schema}, - "required": ["value"], - } - - # The fn just returns the input as-is (validation happens in the loop) - def final_response(**kwargs: Any) -> dict[str, Any]: - return kwargs - - return SamplingTool( - name="final_response", - description=( - "Call this tool to provide your final response. " - "Use this when you have completed the task and are ready to return the result." - ), - parameters=schema, - fn=final_response, - ) - - -# --- Implementation functions for Context methods --- - - -async def sample_step_impl( - context: Context, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] - | None = None, - tool_choice: ToolChoiceOption | str | None = None, - auto_execute_tools: bool = True, - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, - client_available: bool = True, -) -> SampleStep: - """Implementation of Context.sample_step(). - - Make a single LLM sampling call. This is a stateless function that makes - exactly one LLM call and optionally executes any requested tools. - - When ``client_available`` is False (e.g. a modern 2026-07-28 connection with - no back-channel), the client is never used and a configured sampling handler - serves the request; the caller is responsible for raising a clear era error - when no handler can serve it. - """ - # Convert messages to SamplingMessage objects - current_messages = prepare_messages(messages) - - # Convert tools to SamplingTools - sampling_tools = prepare_tools(tools) - sdk_tools: list[SDKTool] | None = ( - [t._to_sdk_tool() for t in sampling_tools] if sampling_tools else None - ) - tool_map: dict[str, SamplingTool] = ( - {t.name: t for t in sampling_tools} if sampling_tools else {} - ) - - # Determine whether to use fallback handler or client - use_fallback = determine_handler_mode( - context, bool(sampling_tools), client_available=client_available - ) - - # Build tool choice - effective_tool_choice: ToolChoice | None = None - if tool_choice is not None: - if tool_choice not in ("auto", "required", "none"): - raise ValueError( - f"Invalid tool_choice: {tool_choice!r}. " - "Must be 'auto', 'required', or 'none'." - ) - effective_tool_choice = ToolChoice.model_validate({"mode": tool_choice}) - - # Effective max_tokens - effective_max_tokens = max_tokens if max_tokens is not None else 512 - - # Make the LLM call - tracer = get_tracer() - span_attrs = { - "mcp.method.name": "sampling/createMessage", - "fastmcp.server.name": context.fastmcp.name, - } - with tracer.start_as_current_span( - "sampling create_message", - kind=SpanKind.CLIENT, - attributes=span_attrs, - record_exception=False, - set_status_on_exception=False, - ) as span: - # Restore: `attributes=span_attrs` above lets on_start hooks and the - # sampler see these values at creation time. But OTel's - # Tracer.start_span builds the span from - # `sampling_result.attributes`, not the `attributes` kwarg directly — - # a custom Sampler whose SamplingResult.attributes defaults to None - # silently drops everything we passed. This only fires when the span - # ends up with no attributes at all, so any sampler that supplied - # attributes of its own — forwarding ours, redacting or replacing - # some, or substituting entirely its own — is left untouched, as is - # an SDK attribute limit that evicted some. - if span.is_recording(): - restore_dropped_attributes(span, span_attrs) - try: - if use_fallback: - response = await call_sampling_handler( - context, - current_messages, - system_prompt=system_prompt, - temperature=temperature, - max_tokens=effective_max_tokens, - model_preferences=model_preferences, - sdk_tools=sdk_tools, - tool_choice=effective_tool_choice, - ) - else: - # Deprecated upstream in SDK v2 but deliberately kept per compat - # directive; removed with the multi-round-trip follow-up. - response = await context.session.create_message( # ty: ignore[deprecated] - messages=current_messages, - system_prompt=system_prompt, - temperature=temperature, - max_tokens=effective_max_tokens, - model_preferences=_parse_model_preferences(model_preferences), - tools=sdk_tools, - tool_choice=effective_tool_choice, - related_request_id=context.origin_request_id, - ) - except Exception as e: - if span.is_recording(): - span.set_attribute("error.type", type(e).__qualname__) - span.record_exception(e) - span.set_status(Status(StatusCode.ERROR, str(e))) - raise - - # Check if this is a tool use response - is_tool_use_response = ( - isinstance(response, CreateMessageResultWithTools) - and response.stop_reason == "toolUse" - ) - - # Always include the assistant response in history - current_messages.append(SamplingMessage(role="assistant", content=response.content)) - - # If not a tool use, return immediately - if not is_tool_use_response: - return SampleStep(response=response, history=current_messages) - - # If not executing tools, return with assistant message but no tool results - if not auto_execute_tools: - return SampleStep(response=response, history=current_messages) - - # Execute tools and add results to history - step_tool_calls = extract_tool_calls(response) - if step_tool_calls: - effective_mask = ( - mask_error_details - if mask_error_details is not None - else settings.mask_error_details - ) - tool_results: list[ToolResultContent] = await execute_tools( - step_tool_calls, - tool_map, - mask_error_details=effective_mask, - tool_concurrency=tool_concurrency, - ) - - if tool_results: - current_messages.append( - SamplingMessage( - role="user", - content=cast(list[SamplingMessageContentBlock], tool_results), - ) - ) - - return SampleStep(response=response, history=current_messages) - - -async def sample_impl( - context: Context, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] - | None = None, - result_type: type[ResultT] | None = None, - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, - client_available: bool = True, -) -> SamplingResult[ResultT]: - """Implementation of Context.sample(). - - Send a sampling request to the client and await the response. This method - runs to completion automatically, executing a tool loop until the LLM - provides a final text response. - - When ``client_available`` is False (e.g. a modern 2026-07-28 connection with - no back-channel), the client is never used and a configured sampling handler - serves the request; the caller is responsible for raising a clear era error - when no handler can serve it. - """ - # Safety limit to prevent infinite loops - max_iterations = 100 - - # Convert tools to SamplingTools - sampling_tools = prepare_tools(tools) - - # Handle structured output with result_type - tool_choice: str | None = None - if result_type is not None and result_type is not str: - final_response_tool = create_final_response_tool(result_type) - sampling_tools = list(sampling_tools) if sampling_tools else [] - sampling_tools.append(final_response_tool) - - # Always require tool calls when result_type is set - the LLM must - # eventually call final_response (text responses are not accepted) - tool_choice = "required" - - # Convert messages for the loop - current_messages: str | Sequence[str | SamplingMessage] = messages - - text_response_retries = 0 - consecutive_validation_failures = 0 - - for _iteration in range(max_iterations): - step = await sample_step_impl( - context, - messages=current_messages, - system_prompt=system_prompt, - temperature=temperature, - max_tokens=max_tokens, - model_preferences=model_preferences, - tools=sampling_tools, - tool_choice=tool_choice, - mask_error_details=mask_error_details, - tool_concurrency=tool_concurrency, - client_available=client_available, - ) - - # Check for final_response tool call for structured output - had_final_response = False - if result_type is not None and result_type is not str and step.is_tool_use: - for tool_call in step.tool_calls: - if tool_call.name == "final_response": - had_final_response = True - # Validate and return the structured result - type_adapter = get_cached_typeadapter(result_type) - - # Unwrap if we wrapped primitives (non-object schemas) - input_data = tool_call.input - original_schema = compress_schema( - type_adapter.json_schema(), prune_titles=True - ) - if ( - original_schema.get("type") != "object" - and isinstance(input_data, dict) - and "value" in input_data - ): - input_data = input_data["value"] - - try: - validated_result = type_adapter.validate_python(input_data) - text = json.dumps( - type_adapter.dump_python(validated_result, mode="json") - ) - return SamplingResult( - text=text, - result=validated_result, - history=step.history, - ) - except ValidationError as e: - consecutive_validation_failures += 1 - if consecutive_validation_failures > _MAX_VALIDATION_RETRIES: - raise RuntimeError( - f"Structured output validation failed " - f"{consecutive_validation_failures} consecutive " - f"times for type {result_type.__name__}: {e}" - ) from e - # Validation failed - add error as tool result - step.history.append( - SamplingMessage( - role="user", - content=[ - ToolResultContent( - type="tool_result", - tool_use_id=tool_call.id, - content=[ - TextContent( - type="text", - text=( - f"Validation error: {e}. " - "Please try again with valid data." - ), - ) - ], - is_error=True, - ) - ], - ) - ) - - # The LLM called tools but not final_response — reset validation counter - if not had_final_response: - consecutive_validation_failures = 0 - - # If not a tool use response, we're done - if not step.is_tool_use: - # For structured output, the LLM must use the final_response tool - if result_type is not None and result_type is not str: - text_response_retries += 1 - if text_response_retries > _MAX_TEXT_RESPONSE_RETRIES: - raise RuntimeError( - f"Expected structured output of type {result_type.__name__}, " - "but the LLM returned a text response instead of calling " - f"the final_response tool ({text_response_retries} attempts)." - ) - # Nudge the LLM to use the tool - step.history.append( - SamplingMessage( - role="user", - content=TextContent( - type="text", - text=( - "You must call the `final_response` tool to provide " - "your answer. Do not respond with text — use the tool." - ), - ), - ) - ) - current_messages = step.history - continue - return SamplingResult( - text=step.text, - result=cast(ResultT, step.text if step.text else ""), - history=step.history, - ) - - # Continue with the updated history - current_messages = step.history - - # After first iteration, reset tool_choice to auto (unless structured output is required) - if result_type is None or result_type is str: - tool_choice = None - - raise RuntimeError(f"Sampling exceeded maximum iterations ({max_iterations})") diff --git a/fastmcp_slim/fastmcp/server/sampling/sampling_tool.py b/fastmcp_slim/fastmcp/server/sampling/sampling_tool.py deleted file mode 100644 index 05063dc53..000000000 --- a/fastmcp_slim/fastmcp/server/sampling/sampling_tool.py +++ /dev/null @@ -1,204 +0,0 @@ -"""SamplingTool for use during LLM sampling requests.""" - -from __future__ import annotations - -import inspect -from collections.abc import Callable -from typing import Any - -from mcp_types import TextContent -from mcp_types import Tool as SDKTool -from pydantic import ConfigDict - -from fastmcp.exceptions import AuthorizationError -from fastmcp.server.auth.authorization import AuthContext, run_auth_checks -from fastmcp.server.dependencies import get_access_token -from fastmcp.tools.base import ToolResult -from fastmcp.tools.function_parsing import ParsedFunction -from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool_transform import TransformedTool -from fastmcp.utilities.types import FastMCPBaseModel - - -class SamplingTool(FastMCPBaseModel): - """A tool that can be used during LLM sampling. - - SamplingTools bundle a tool's schema (name, description, parameters) with - an executor function, enabling servers to execute agentic workflows where - the LLM can request tool calls during sampling. - - In most cases, pass functions directly to ctx.sample(): - - def search(query: str) -> str: - '''Search the web.''' - return web_search(query) - - result = await context.sample( - messages="Find info about Python", - tools=[search], # Plain functions work directly - ) - - Create a SamplingTool explicitly when you need custom name/description: - - tool = SamplingTool.from_function(search, name="web_search") - """ - - name: str - description: str | None = None - parameters: dict[str, Any] - fn: Callable[..., Any] - sequential: bool = False - - model_config = ConfigDict(arbitrary_types_allowed=True) - - async def run(self, arguments: dict[str, Any] | None = None) -> Any: - """Execute the tool with the given arguments. - - Args: - arguments: Dictionary of arguments to pass to the tool function. - - Returns: - The result of executing the tool function. - """ - if arguments is None: - arguments = {} - - result = self.fn(**arguments) - if inspect.isawaitable(result): - result = await result - return result - - def _to_sdk_tool(self) -> SDKTool: - """Convert to an mcp_types.Tool for SDK compatibility. - - This is used internally when passing tools to the MCP SDK's - create_message() method. - """ - return SDKTool( - name=self.name, - description=self.description, - input_schema=self.parameters, - ) - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - *, - name: str | None = None, - description: str | None = None, - sequential: bool = False, - ) -> SamplingTool: - """Create a SamplingTool from a function. - - The function's signature is analyzed to generate a JSON schema for - the tool's parameters. Type hints are used to determine parameter types. - - Args: - fn: The function to create a tool from. - name: Optional name override. Defaults to the function's name. - description: Optional description override. Defaults to the function's docstring. - sequential: If True, this tool requires sequential execution and prevents - parallel execution of all tools in the batch. Set to True for tools - with shared state, file writes, or other operations that cannot run - concurrently. Defaults to False. - - Returns: - A SamplingTool wrapping the function. - - Raises: - ValueError: If the function is a lambda without a name override. - """ - parsed = ParsedFunction.from_function(fn, validate=True) - - if name is None and parsed.name == "": - raise ValueError("You must provide a name for lambda functions") - - return cls( - name=name or parsed.name, - description=description if description is not None else parsed.description, - parameters=parsed.input_schema, - fn=parsed.fn, - sequential=sequential, - ) - - @classmethod - def from_callable_tool( - cls, - tool: FunctionTool | TransformedTool, - *, - name: str | None = None, - description: str | None = None, - ) -> SamplingTool: - """Create a SamplingTool from a FunctionTool or TransformedTool. - - Reuses existing server tools in sampling contexts. For TransformedTool, - the tool's .run() method is used to ensure proper argument transformation, - and the ToolResult is automatically unwrapped. - - Args: - tool: A FunctionTool or TransformedTool to convert. - name: Optional name override. Defaults to tool.name. - description: Optional description override. Defaults to tool.description. - - Raises: - TypeError: If the tool is not a FunctionTool or TransformedTool. - """ - # Validate that the tool is a supported type - if not isinstance(tool, (FunctionTool, TransformedTool)): - raise TypeError( - f"Expected FunctionTool or TransformedTool, got {type(tool).__name__}. " - "Only callable tools can be converted to SamplingTools." - ) - - # Both FunctionTool and TransformedTool need .run() to ensure proper - # result processing (serializers, output_schema, wrap-result flags) - async def wrapper(**kwargs: Any) -> Any: - # Enforce per-tool auth checks, mirroring what the server - # dispatcher does for direct tool calls. Without this, an - # auth-protected tool wrapped as a SamplingTool could be - # invoked by the LLM during sampling without authorization. - if tool.auth is not None: - # Late import to avoid circular import with context.py - from fastmcp.server.context import _current_transport - - is_stdio = _current_transport.get() == "stdio" - if not is_stdio: - token = get_access_token() - ctx = AuthContext(token=token, component=tool) - if not await run_auth_checks(tool.auth, ctx): - raise AuthorizationError( - f"Authorization failed for tool '{tool.name}': " - "insufficient permissions" - ) - - result = await tool.run(kwargs) - # Unwrap ToolResult - extract the actual value - if isinstance(result, ToolResult): - # If there's structured_content, use that - if result.structured_content is not None: - # Check tool's schema - this is the source of truth - if tool.output_schema and tool.output_schema.get( - "x-fastmcp-wrap-result" - ): - # Tool wraps results: {"result": value} -> value - return result.structured_content.get("result") - else: - # No wrapping: use structured_content directly - return result.structured_content - # Otherwise, extract from text content - if result.content and len(result.content) > 0: - first_content = result.content[0] - if isinstance(first_content, TextContent): - return first_content.text - return result - - fn = wrapper - - # Extract the callable function, name, description, and parameters - return cls( - name=name or tool.name, - description=description or tool.description, - parameters=tool.parameters, - fn=fn, - ) diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index 15e03c5f2..6fb97bace 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -31,6 +31,7 @@ from mcp_types import ( CallToolRequestParams, ToolAnnotations, ) +from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY from pydantic import AnyUrl from pydantic import ValidationError as PydanticValidationError from starlette.routing import BaseRoute @@ -102,7 +103,6 @@ from fastmcp.utilities.versions import ( if TYPE_CHECKING: from fastmcp.client import Client from fastmcp.client.client import SDKServer - from fastmcp.client.sampling import SamplingHandler from fastmcp.client.transports import ClientTransport, ClientTransportT from fastmcp.server.extensions import ServerExtension from fastmcp.server.providers.openapi import ComponentFn as OpenAPIComponentFn @@ -145,8 +145,9 @@ def _version_request_meta( # The MCP SDK warns "Tool X not listed, no validation will be performed" -# for every call to app-only tools (hidden from list_tools by design). -# This fires even when validate_input=False. Suppress it. +# for every call addressed by hashed backend name, since that address is +# an identity rather than a listed tool name. This fires even when +# validate_input=False. Suppress it. class _SuppressUnlistedToolWarning(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: return "not listed, no validation" not in record.getMessage() @@ -178,11 +179,13 @@ _REMOVED_KWARGS: dict[str, str] = { "include_tags": "Use `server.enable(tags=..., only=True)` after creating the server.", "exclude_tags": "Use `server.disable(tags=...)` after creating the server.", "tool_transformations": "Use `server.add_transform(ToolTransform(...))` after creating the server.", + "sampling_handler": "Server-initiated sampling was removed from MCP by SEP-2577. Call an LLM directly from your tool.", + "sampling_handler_behavior": "Server-initiated sampling was removed from MCP by SEP-2577. Call an LLM directly from your tool.", } def _check_removed_kwargs(kwargs: dict[str, Any]) -> None: - """Raise helpful TypeErrors for kwargs removed in v3.""" + """Raise helpful TypeErrors for kwargs FastMCP no longer accepts.""" for key in kwargs: if key in _REMOVED_KWARGS: raise TypeError( @@ -221,64 +224,18 @@ def _get_auth_context() -> tuple[bool, Any]: return (False, get_access_token()) -def _is_backend_tool(tool: Tool) -> bool: - """Check whether a tool is handled specially as backend tool +def _tool_identity(tool: Tool) -> str | None: + """Read a tool's stable identity hash, if it carries one.""" + from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY - Tools registered via ``@app.tool()`` (without ``model=True``) have - ``meta["ui"]["visibility"] == ["app"]`` — they are callable by app UIs - but should not appear in tool list the client passes to the model. - - They are handled specially for in various ways - e.g. they are looked - up via get_app_tool(), and don't appear in the tools/list output. - (FIXME: the latter isn't correct behavior according to the mcp-apps spec.) - - Returns True (a backend tool) when: - - The tool has ``meta.fastmcp.app``. - - The tool has ``meta.ui.visibility``. - - The visibility is precisely ``["app"]``. - - Returns False otherwise. - """ meta = tool.meta if not meta: - return False - fastmcp = meta.get("fastmcp") - if not isinstance(fastmcp, dict): - return False - if fastmcp.get("app") is None: - return False - ui = meta.get("ui") - if not isinstance(ui, dict): - return False - visibility = ui.get("visibility") - if not isinstance(visibility, list): - return False - return len(visibility) == 1 and visibility[0] == "app" - - -def _is_app_visible(tool: Tool) -> bool: - """Check whether a tool has explicitly opted into app-callable visibility. - - Gates the dispatcher's hashed-name routing path: only tools whose - ``meta.ui.visibility`` list contains ``"app"`` can be reached via - ``_`` calls. Tools without an explicit visibility - declaration are NOT app-callable — they must be reached by their - display name through the normal transform-aware resolution path. - - This is the inverse of the "everything is dot-callable" trap: the - hashed-name path is an opt-in mechanism for FastMCPApp backend tools, - not a general bypass for arbitrary tools. - """ - meta = tool.meta - if not meta: - return False - ui = meta.get("ui") - if not isinstance(ui, dict): - return False - visibility = ui.get("visibility") - if not isinstance(visibility, list): - return False - return "app" in visibility + return None + fastmcp_meta = meta.get("fastmcp") + if not isinstance(fastmcp_meta, dict): + return None + identity = fastmcp_meta.get(TOOL_HASH_META_KEY) + return identity if isinstance(identity, str) else None @asynccontextmanager @@ -355,8 +312,6 @@ class FastMCP( cache_scope: Literal["public", "private"] | None = None, tasks: bool | None = None, session_state_store: AsyncKeyValue | None = None, - sampling_handler: SamplingHandler | None = None, - sampling_handler_behavior: Literal["always", "fallback"] | None = None, client_log_level: mcp_types.LoggingLevel | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, **kwargs: Any, @@ -538,11 +493,6 @@ class FastMCP( # Set up MCP protocol handlers self._setup_handlers() - self.sampling_handler: SamplingHandler | None = sampling_handler - self.sampling_handler_behavior: Literal["always", "fallback"] = ( - sampling_handler_behavior or "fallback" - ) - def __repr__(self) -> str: return f"{type(self).__name__}({self.name!r})" @@ -733,7 +683,7 @@ class FastMCP( """Replace placeholder Prefab URIs with per-tool hashed ones. For each tool whose ``meta.ui.resourceUri`` is the placeholder, - reads the tool's stored hash from ``meta.fastmcp._tool_hash`` + reads the tool's stored hash from ``meta.fastmcp.tool_hash`` and rewrites the URI to the per-tool form. Also strips CSP from tool meta (it belongs on the resource). Produces ``model_copy`` views — originals are untouched. @@ -747,6 +697,78 @@ class FastMCP( rewrite_tool_meta_for_wire(t) if _is_prefab_tool(t) else t for t in tools ] + async def _rebind_prefab_tool_names(self, result: Any) -> Any: + """Re-address a Prefab payload's tool references to this server's names. + + Runs on the way out of every ``tools/call``, above the middleware + chain so a payload is re-addressed however it was produced. Servers + unwind innermost-first, so the outermost server rewrites last and its + names — the only ones a client can actually invoke — are what ship. + + A call does not always answer with a tool result: submitting a task + answers with the task's metadata. Anything that is not a tool result + passes through untouched. + + An identity claimed by more than one tool is not bound. That happens + when one app is composed into a server twice, which leaves no fact + anywhere in the listing that says which copy a UI belongs to. The + reference keeps its identity-addressed form, and the dispatcher + reports the ambiguity rather than binding to a coin flip. + """ + from fastmcp.server.providers.prefab_payload import ( + payload_has_identities, + rewrite_payload_tool_names, + ) + + if not isinstance(result, ToolResult): + return result + + payload = result.structured_content + if not payload_has_identities(payload): + return result + + # Binding is safe only where one identity, one name, and one + # component all agree. Each is tracked separately: collapsing them + # early is what lets a duplicated app pass as a single tool. + # + # The middleware chain runs, because the binding has to describe the + # listing a client will actually see. Middleware adds, removes and + # shadows tools — an injected tool sharing a backend's name owns that + # name at call time, and a listing taken beneath middleware would not + # know it exists. + claimed_by: dict[str, list[Tool]] = {} + owners_of: dict[str, set[str | None]] = {} + for tool in await self.list_tools(): + identity = _tool_identity(tool) + owners_of.setdefault(tool.name, set()).add(identity) + if identity is not None: + claimed_by.setdefault(identity, []).append(tool) + + def resolve(identity: str) -> str | None: + tools = claimed_by.get(identity, []) + names = {tool.name for tool in tools} + if len(names) != 1: + # Several names carry this identity: the app is composed more + # than once and nothing says which copy the UI belongs to. + return None + + # One name can still be several components. `key` is the canonical + # identity — type, name and version — so versions of one tool have + # distinct keys while copies of one app repeat a key. A repeat + # means two components are indistinguishable, which is worse than + # the renamed case, not better. + if len({tool.key for tool in tools}) != len(tools): + return None + + (name,) = names + # And the name has to lead back. Two apps can each expose `save`, + # or a plain tool can share the name — binding then hands one + # app's button to someone else's implementation. + return name if owners_of.get(name) == {identity} else None + + rewrite_payload_tool_names(payload, resolve) + return result + # ------------------------------------------------------------------------- # Provider interface overrides - inherited from AggregateProvider # ------------------------------------------------------------------------- @@ -805,7 +827,7 @@ class FastMCP( async def list_tools(self, *, run_middleware: bool = True) -> Sequence[Tool]: """List all enabled tools from providers. - Overrides Provider.list_tools() to add visibility filtering, auth filtering, + Overrides Provider.list_tools() to add enabled filtering, auth filtering, and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. """ @@ -825,11 +847,14 @@ class FastMCP( # Core logic: list tools with server_span("tools/list", "tools/list", self.name, "tool", ""): - # Get all tools, apply session transforms, then filter enabled - # and model-visible (app-only tools are hidden from the model). + # Get all tools, apply session transforms, then filter enabled. + # App-only tools (meta.ui.visibility == ["app"]) are listed: + # the mcp-apps spec puts visibility filtering on the host, and + # a tool absent from tools/list cannot be forwarded by any + # intermediary that routes by name. tools = list(await super().list_tools()) tools = await apply_session_transforms(tools) - tools = [t for t in tools if is_enabled(t) and not _is_backend_tool(t)] + tools = [t for t in tools if is_enabled(t)] # Rewrite per-tool Prefab renderer URIs based on the tool's # mount-point address. The walk pairs each tool with the @@ -887,7 +912,7 @@ class FastMCP( ) -> Tool | None: """Get a tool by name, filtering disabled tools. - Overrides Provider.get_tool() to add visibility filtering after all + Overrides Provider.get_tool() to filter disabled tools after all transforms (including session-level) have been applied. This ensures session transforms can override provider-level disables. @@ -907,18 +932,18 @@ class FastMCP( # Apply session transforms to single item tools = await apply_session_transforms([tool]) - if tools and is_enabled(tools[0]) and not _is_backend_tool(tools[0]): + if tools and is_enabled(tools[0]): return tools[0] - # The highest version is disabled (or app-only). If an explicit version - # was requested, respect that. Otherwise fall back to the next-highest - # enabled, model-visible version. + # The highest version is disabled. If an explicit version was + # requested, respect that. Otherwise fall back to the next-highest + # enabled version. if version is not None: return None all_tools = [t for t in await super().list_tools() if t.name == name] all_tools = list(await apply_session_transforms(all_tools)) - enabled = [t for t in all_tools if is_enabled(t) and not _is_backend_tool(t)] + enabled = [t for t in all_tools if is_enabled(t)] skip_auth, token = _get_auth_context() authorized: list[Tool] = [] @@ -1403,7 +1428,7 @@ class FastMCP( # the whole thing (so it observes every call), and the # interceptors sit between it and the tool body (so each is the # last gate before execution). - return await self._dispatch_component_middleware( + dispatched = await self._dispatch_component_middleware( context=mw_context, call_next=self._compose_tool_call_interceptors( lambda context: self.call_tool( @@ -1414,6 +1439,10 @@ class FastMCP( ) ), ) + # Above the chain, so a Prefab payload is re-addressed however + # it was produced — middleware can answer a call itself, and + # such a result never reaches the core path below. + return await self._rebind_prefab_tool_names(dispatched) # Core logic: find and execute tool with server_span( @@ -1484,6 +1513,24 @@ class FastMCP( ) raise except Exception as e: + # Most MCPErrors raised under a tool describe how the call + # went — a timeout, an upstream error a proxy forwarded — + # and are masked into an `isError` result like any other + # failure. A missing-client-capability error is different: + # it says the request cannot be serviced at all, and + # SEP-2575 requires it on the wire as -32021 (HTTP 400). + # Flattening it into a result would drop the code and tell + # the client the call had succeeded. + if ( + isinstance(e, MCPError) + and e.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY + ): + logger.debug( + "Tool %r requires a client capability the client did " + "not declare", + name, + ) + raise logger.exception(f"Error calling tool {name!r}") # Handle actionable errors that should reach the LLM # even when masking is enabled diff --git a/fastmcp_slim/fastmcp/server/telemetry.py b/fastmcp_slim/fastmcp/server/telemetry.py index 53350e8e8..4d073d45b 100644 --- a/fastmcp_slim/fastmcp/server/telemetry.py +++ b/fastmcp_slim/fastmcp/server/telemetry.py @@ -4,14 +4,23 @@ from collections.abc import Generator from contextlib import contextmanager from contextvars import ContextVar +from opentelemetry import context as otel_context from opentelemetry.context import Context -from opentelemetry.trace import Span, SpanKind, Status, StatusCode, get_current_span +from opentelemetry.trace import ( + INVALID_SPAN, + Span, + SpanKind, + Status, + StatusCode, + get_current_span, +) from fastmcp.exceptions import ToolError as _ToolError from fastmcp.telemetry import ( extract_trace_context, get_tracer, restore_dropped_attributes, + telemetry_mode, ) # Marker attribute set on the SERVER span opened at the FastMCP middleware seam @@ -87,6 +96,32 @@ def _get_parent_trace_context() -> Context | None: return None +@contextmanager +def _propagation_only_span() -> Generator[Span, None, None]: + """Attach the incoming `_meta` trace context without creating a span. + + This is what separates `propagation_only` from `off`. Both create no + FastMCP spans, but `off` is fully transparent while `propagation_only` + still has to *parent* whatever the request goes on to do: without the + attach here, the trace context carried in `_meta` would be extracted and + then thrown away, and a span created inside a tool handler — by the user or + by the outer instrumentation layer that owns the MCP hierarchy — would + start a brand new trace instead of continuing the caller's. + + Yields `INVALID_SPAN`, which is non-recording, so callers' `is_recording()` + guards skip attribute and error bookkeeping on it. + """ + parent_context = _get_parent_trace_context() + if parent_context is None: + yield INVALID_SPAN + return + token = otel_context.attach(parent_context) + try: + yield INVALID_SPAN + finally: + otel_context.detach(token) + + def _build_server_span_attrs( method: str, server_name: str, @@ -138,7 +173,16 @@ def seam_span(method: str, server_name: str) -> Generator[Span, None, None]: opening a second one. Exceptions raised anywhere below the seam — including rejections *before* the high-level path (auth, not-found, middleware vetoes) that would otherwise produce no SERVER span at all — are recorded here. + + In `propagation_only` mode no span is opened at all — this is the one place + that has to know the difference, because the seam is where the incoming + `_meta` parent context is applied for the whole request. """ + if telemetry_mode() == "propagation_only": + with _propagation_only_span() as span: + yield span + return + attrs = { SEAM_SPAN_MARKER: True, "mcp.method.name": method, @@ -198,7 +242,17 @@ def server_span( new SERVER span as before. Automatically records any exception on the span and sets error status. + + In `propagation_only` mode no span is opened or enriched. The seam has + normally already attached the incoming parent context for this request; + doing it again here is a no-op, and covers the in-process callers that + bypass the dispatcher and so never reach the seam at all. """ + if telemetry_mode() == "propagation_only": + with _propagation_only_span() as span: + yield span + return + attrs = _build_server_span_attrs( method, server_name, diff --git a/fastmcp_slim/fastmcp/server/transforms/catalog.py b/fastmcp_slim/fastmcp/server/transforms/catalog.py index 936fcd9b3..e1ea30486 100644 --- a/fastmcp_slim/fastmcp/server/transforms/catalog.py +++ b/fastmcp_slim/fastmcp/server/transforms/catalog.py @@ -49,6 +49,7 @@ from collections.abc import Sequence from contextvars import ContextVar from typing import TYPE_CHECKING +from fastmcp.apps.config import is_model_visible from fastmcp.server.transforms import Transform from fastmcp.utilities.versions import dedupe_with_versions @@ -177,6 +178,16 @@ class CatalogTransform(Transform): of each tool is returned — matching what protocol handlers expose on the wire. + Tools the model may not see are excluded. A catalog is read by the + model as tool output rather than advertised as ``tools/list``, so the + host filtering the spec relies on never applies to it — this is the + only place the declaration can be enforced. + + Visibility is checked after deduplication, on the version a bare name + actually reaches. Checking first would let a model-visible older + version advertise a name whose highest version is app-only, and the + call would run the version nobody was shown. + Args: ctx: The current request context. run_middleware: Whether to run middleware on the inner call. @@ -188,7 +199,8 @@ class CatalogTransform(Transform): tools = await ctx.fastmcp.list_tools(run_middleware=run_middleware) finally: self._bypass.reset(token) - return dedupe_with_versions(tools, lambda t: t.name) + selected = dedupe_with_versions(tools, lambda t: t.name) + return [tool for tool in selected if is_model_visible(tool)] async def get_resource_catalog( self, ctx: Context, *, run_middleware: bool = True diff --git a/fastmcp_slim/fastmcp/server/transforms/search/base.py b/fastmcp_slim/fastmcp/server/transforms/search/base.py index cfeac6909..cdb47680b 100644 --- a/fastmcp_slim/fastmcp/server/transforms/search/base.py +++ b/fastmcp_slim/fastmcp/server/transforms/search/base.py @@ -31,6 +31,7 @@ from abc import abstractmethod from collections.abc import Awaitable, Callable, Sequence from typing import Annotated, Any +from fastmcp.exceptions import NotFoundError from fastmcp.server.context import Context from fastmcp.server.transforms import GetToolNext from fastmcp.server.transforms.catalog import CatalogTransform @@ -240,6 +241,13 @@ class BaseSearchTransform(CatalogTransform): raise ValueError( f"'{name}' is a synthetic search tool and cannot be called via the call_tool proxy" ) + # The name comes from the model, so this proxy is a second way + # into the server that no host mediates. It may reach only what + # the model was allowed to discover. + if not any( + tool.name == name for tool in await transform.get_tool_catalog(ctx) + ): + raise NotFoundError(f"Unknown tool: {name!r}") return await ctx.fastmcp.call_tool(name, arguments) return Tool.from_function(fn=call_tool, name=self._call_tool_name) diff --git a/fastmcp_slim/fastmcp/settings.py b/fastmcp_slim/fastmcp/settings.py index 352b383e2..d442d7218 100644 --- a/fastmcp_slim/fastmcp/settings.py +++ b/fastmcp_slim/fastmcp/settings.py @@ -20,6 +20,8 @@ ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env") LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +TELEMETRY_MODE = Literal["native", "propagation_only", "off"] + MCP_LOG_LEVEL = Literal[ "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency" ] @@ -104,24 +106,6 @@ class Settings(BaseSettings): ), ] = True - enable_telemetry: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - Whether FastMCP's native OpenTelemetry instrumentation is active. - Enabled by default: FastMCP uses only the OpenTelemetry API, so - span creation is a no-op with negligible overhead unless an - OpenTelemetry SDK and exporter are configured. Set to False to - turn instrumentation off entirely, in which case FastMCP's span - helpers become a transparent pass-through: no FastMCP spans are - created even when an SDK is configured, and the surrounding OTel - trace context is left untouched. - """ - ) - ), - ] = True - deprecation_warnings: Annotated[ bool, Field( @@ -167,6 +151,31 @@ class Settings(BaseSettings): ), ] = True + telemetry_mode: Annotated[ + TELEMETRY_MODE, + Field( + description=inspect.cleandoc( + """ + Controls FastMCP's native OpenTelemetry instrumentation. + + - `native` (default): FastMCP creates MCP spans and propagates + trace context through request `_meta`. FastMCP uses only the + OpenTelemetry API, so span creation is a no-op with negligible + overhead unless an SDK and exporter are configured. + - `propagation_only`: FastMCP still injects and extracts trace + context, and still parents downstream spans from the incoming + `_meta` context, but creates none of its own MCP spans. Use + this when another instrumentation layer owns the MCP span + hierarchy and FastMCP's spans would duplicate it. + - `off`: FastMCP's span helpers become a transparent + pass-through. No spans are created even when an SDK is + configured, and the surrounding OTel context is left + untouched — no trace context is extracted or attached. + """ + ), + ), + ] = "native" + client_init_timeout: Annotated[ float | None, Field( diff --git a/fastmcp_slim/fastmcp/telemetry.py b/fastmcp_slim/fastmcp/telemetry.py index dc0edf32a..cf379818b 100644 --- a/fastmcp_slim/fastmcp/telemetry.py +++ b/fastmcp_slim/fastmcp/telemetry.py @@ -23,7 +23,7 @@ Example usage with SDK: from collections.abc import Iterator, Mapping from contextlib import contextmanager -from typing import Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from opentelemetry import context as otel_context from opentelemetry import propagate, trace @@ -40,6 +40,9 @@ from opentelemetry.trace import ( from opentelemetry.trace import get_tracer as otel_get_tracer from opentelemetry.util import types as otel_types +if TYPE_CHECKING: + from fastmcp.settings import TELEMETRY_MODE as TelemetryMode + INSTRUMENTATION_NAME = "fastmcp" TRACE_PARENT_KEY = "traceparent" @@ -77,28 +80,71 @@ class _DisabledTracer(NoOpTracer): _DISABLED_TRACER = _DisabledTracer() +_SUPPRESS_KEY = otel_context.create_key("fastmcp_suppress_telemetry") + + +def telemetry_mode() -> "TelemetryMode": + """Resolve the effective telemetry mode for the current context. + + This is `fastmcp.settings.telemetry_mode`, except that an active + `suppress_fastmcp_telemetry()` block downgrades `native` to + `propagation_only`. Suppression never upgrades or overrides `off`: `off` + means FastMCP touches nothing, and a narrower request to skip FastMCP's + spans cannot re-enable the context propagation `off` deliberately omits. + """ + import fastmcp + + mode: TelemetryMode = fastmcp.settings.telemetry_mode + if mode == "native" and otel_context.get_value(_SUPPRESS_KEY): + return "propagation_only" + return mode + + +def native_spans_enabled() -> bool: + """Whether FastMCP should create its own spans right now.""" + return telemetry_mode() == "native" + + +@contextmanager +def suppress_fastmcp_telemetry() -> Iterator[None]: + """Suppress FastMCP's own spans without disabling trace propagation. + + Scoped equivalent of `telemetry_mode="propagation_only"`, for callers that + embed FastMCP inside their own instrumented stack and want to own the MCP + span hierarchy for a specific block. Narrower than OpenTelemetry's global + instrumentation suppression: only FastMCP's spans are skipped, so nested + instrumentation (HTTP clients, databases) keeps emitting, and trace context + still flows through `_meta` so those spans are parented correctly. + + Has no effect when `telemetry_mode` is already `off`. + """ + token = otel_context.attach(otel_context.set_value(_SUPPRESS_KEY, True)) + try: + yield + finally: + otel_context.detach(token) + def get_tracer(version: str | None = None) -> Tracer: """Get the FastMCP tracer for creating spans. Instrumentation is on by default. FastMCP uses only the OpenTelemetry API, so span creation is a no-op with negligible overhead unless an OpenTelemetry - SDK and exporter are configured. Set `fastmcp.settings.enable_telemetry` to - False (env `FASTMCP_ENABLE_TELEMETRY=false`) to turn instrumentation off - entirely, in which case this returns a pass-through tracer that leaves the - current OTel context untouched even when an SDK is configured. + SDK and exporter are configured. When `fastmcp.settings.telemetry_mode` is + `propagation_only` or `off` — or the caller is inside a + `suppress_fastmcp_telemetry()` block — this returns a pass-through tracer + that creates no spans and leaves the current OTel context untouched even + when an SDK is configured. Args: version: Optional version string for the instrumentation Returns: - A tracer instance. Returns a non-attaching pass-through tracer if - telemetry is disabled; span creation is otherwise a no-op unless an SDK - is configured. + A tracer instance. Returns a non-attaching pass-through tracer when + FastMCP's own spans are disabled; span creation is otherwise a no-op + unless an SDK is configured. """ - import fastmcp - - if not fastmcp.settings.enable_telemetry: + if not native_spans_enabled(): return _DISABLED_TRACER return otel_get_tracer(INSTRUMENTATION_NAME, version) @@ -115,6 +161,11 @@ def inject_trace_context( A new dict containing the original meta (if any) plus trace context keys, or None if no trace context to inject and meta was None """ + # `off` means FastMCP touches nothing, outbound propagation included. + # `propagation_only` still injects — carrying context is the whole point. + if telemetry_mode() == "off": + return meta + carrier: dict[str, str] = {} propagate.inject(carrier) @@ -222,6 +273,10 @@ def extract_trace_context(meta: dict[str, Any] | None) -> Context: An OpenTelemetry Context with the extracted trace context, or the current context if no trace context found or already in a trace """ + # `off` means FastMCP touches nothing, including the surrounding context. + if telemetry_mode() == "off": + return otel_context.get_current() + # Don't override existing trace context (e.g., from HTTP propagation) current_span = trace.get_current_span() if current_span.get_span_context().is_valid: @@ -237,7 +292,12 @@ def extract_trace_context(meta: dict[str, Any] | None) -> Context: carrier["tracestate"] = str(meta[TRACE_STATE_KEY]) if carrier: - return propagate.extract(carrier) + # Extract *onto the current context* rather than a fresh root, so the + # incoming parent is added without discarding context values the + # caller already established — active baggage, and FastMCP's own + # suppression marker, which would otherwise be dropped the moment the + # extracted context is attached. + return propagate.extract(carrier, context=otel_context.get_current()) return otel_context.get_current() @@ -248,6 +308,9 @@ __all__ = [ "extract_trace_context", "get_tracer", "inject_trace_context", + "native_spans_enabled", "record_span_error", "restore_dropped_attributes", + "suppress_fastmcp_telemetry", + "telemetry_mode", ] diff --git a/fastmcp_slim/fastmcp/tools/__init__.py b/fastmcp_slim/fastmcp/tools/__init__.py index 45a3f74ad..d3f7303fc 100644 --- a/fastmcp_slim/fastmcp/tools/__init__.py +++ b/fastmcp_slim/fastmcp/tools/__init__.py @@ -1,15 +1,7 @@ -import sys - from .function_tool import FunctionTool, tool from .base import InputRequiredToolResult, Tool, ToolResult from .tool_transform import forward, forward_raw -# Backward compat: tool.py was renamed to base.py to stop Pyright from resolving -# `from fastmcp.tools import tool` as the submodule instead of the decorator function. -# This shim keeps `from fastmcp.tools.tool import Tool` working at runtime. -# Safe to remove once we're confident no external code imports from the old path. -sys.modules[f"{__name__}.tool"] = sys.modules[f"{__name__}.base"] - __all__ = [ "FunctionTool", "InputRequiredToolResult", diff --git a/fastmcp_slim/fastmcp/tools/base.py b/fastmcp_slim/fastmcp/tools/base.py index fcf5e7e18..ad21f1253 100644 --- a/fastmcp_slim/fastmcp/tools/base.py +++ b/fastmcp_slim/fastmcp/tools/base.py @@ -518,15 +518,17 @@ def _get_tool_resolver(app_name: str | None = None) -> Callable[..., str] | None def _prefab_to_json(app: Any, fastmcp_app_name: str | None = None) -> dict[str, Any]: - """Call PrefabApp.to_json() with the hash-based resolver. + """Serialize a PrefabApp, addressing its peer-tool references by identity. - The resolver prefixes peer-tool references with a deterministic hash - derived from the app name + tool name. The dispatcher recognizes that - format and routes calls via ``get_tool_by_hash`` which walks the - provider tree recursively — same pattern as the old ``get_app_tool``. + The resolver writes each reference as ``_``, and the + identity behind it is recorded in the payload's meta so that servers + can re-address the reference on the way out without losing track of + what it points at. """ + from fastmcp.server.providers.prefab_payload import annotate_payload_identities + data = app.to_json(tool_resolver=_get_tool_resolver(fastmcp_app_name)) - return data + return annotate_payload_identities(data) def _get_fastmcp_app_name(tool: Tool) -> str | None: diff --git a/fastmcp_slim/fastmcp/tools/tool_transform.py b/fastmcp_slim/fastmcp/tools/tool_transform.py index effb5386b..2ac0a2dc3 100644 --- a/fastmcp_slim/fastmcp/tools/tool_transform.py +++ b/fastmcp_slim/fastmcp/tools/tool_transform.py @@ -242,6 +242,50 @@ class ArgTransformConfig(FastMCPBaseModel): return ArgTransform(**self.model_dump(exclude_unset=True)) # pyright: ignore[reportAny] +#: Meta namespaces the framework owns. An override replaces the caller-facing +#: meta wholesale, but these carry a component's app membership, identity, and +#: visibility — what intermediaries use to recognize a tool they are +#: forwarding. Both are needed together: an identity that survives a rename +#: while its ``ui.visibility`` marker does not leaves a tool that can be named +#: but no longer answers to its identity. +_FRAMEWORK_META_NAMESPACES = ("fastmcp", "ui") + + +def _apply_meta_override( + source_meta: dict[str, Any] | None, + override: dict[str, Any] | None | NotSetT, +) -> dict[str, Any] | None: + """Apply a transform's ``meta=`` override, preserving framework namespaces. + + An override replaces the caller-facing meta wholesale, which is what users + expect. Framework-owned namespaces are carried across regardless, since a + transform that renames a tool must not silently unwire it — values the + override supplies for those namespaces still win key by key. + """ + if isinstance(override, NotSetT): + return source_meta + + source = source_meta or {} + preserved = { + namespace: dict(source[namespace]) + for namespace in _FRAMEWORK_META_NAMESPACES + if isinstance(source.get(namespace), dict) and source[namespace] + } + + if override is None: + return preserved or None + + merged = dict(override) + for namespace, source_values in preserved.items(): + override_values = override.get(namespace) + merged[namespace] = ( + {**source_values, **override_values} + if isinstance(override_values, dict) + else source_values + ) + return merged + + class TransformedTool(Tool): """A tool that is transformed from another tool. @@ -590,7 +634,7 @@ class TransformedTool(Tool): description if not isinstance(description, NotSetT) else tool.description ) final_title = title if not isinstance(title, NotSetT) else tool.title - final_meta = meta if not isinstance(meta, NotSetT) else tool.meta + final_meta = _apply_meta_override(tool.meta, meta) final_annotations = ( annotations if not isinstance(annotations, NotSetT) else tool.annotations ) diff --git a/fastmcp_slim/fastmcp/utilities/authorization.py b/fastmcp_slim/fastmcp/utilities/authorization.py index 4e193c71d..5ae4ae184 100644 --- a/fastmcp_slim/fastmcp/utilities/authorization.py +++ b/fastmcp_slim/fastmcp/utilities/authorization.py @@ -9,9 +9,9 @@ from __future__ import annotations import inspect import logging -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from fastmcp.exceptions import AuthorizationError @@ -47,55 +47,270 @@ class AuthContext: AuthCheck = Callable[[AuthContext], bool] | Callable[[AuthContext], Awaitable[bool]] -def require_scopes(*scopes: str) -> AuthCheck: - """Require all of the given OAuth scopes.""" - required = set(scopes) +class _ScopeAwareCheck: + """Base for auth checks that can name the scopes a token is missing. - def check(ctx: AuthContext) -> bool: + Ordinary auth checks are opaque booleans: on denial they reveal nothing + about *why*. Scope-based checks expose their unmet requirements through + `missing_scopes` so a shortfall can be surfaced as a spec-correct + ``insufficient_scope`` step-up (SEP-2350 / RFC 6750 §3) naming exactly what + the caller must re-authorize for. + """ + + def missing_scopes(self, ctx: AuthContext) -> set[str]: + """Return the required scopes the token lacks (empty if satisfied). + + An absent token yields an empty set: a missing token is an + authentication problem, not a scope shortfall, and must not be turned + into an ``insufficient_scope`` challenge (RFC 6750 §3.1). + """ + raise NotImplementedError + + +class _RequireScopes(_ScopeAwareCheck): + """Callable auth check requiring all of a fixed set of OAuth scopes.""" + + def __init__(self, scopes: tuple[str, ...]) -> None: + self.required_scopes: frozenset[str] = frozenset(scopes) + + def __call__(self, ctx: AuthContext) -> bool: if ctx.token is None: return False - return required.issubset(set(ctx.token.scopes)) + return self.required_scopes.issubset(set(ctx.token.scopes)) - return check + def missing_scopes(self, ctx: AuthContext) -> set[str]: + if ctx.token is None: + return set() + return set(self.required_scopes) - set(ctx.token.scopes) + + +class _RestrictTag(_ScopeAwareCheck): + """Callable auth check requiring scopes only when a component has a tag.""" + + def __init__(self, tag: str, scopes: list[str]) -> None: + self.tag = tag + self.required_scopes: frozenset[str] = frozenset(scopes) + + def __call__(self, ctx: AuthContext) -> bool: + if self.tag not in ctx.component.tags: + return True + if ctx.token is None: + return False + return self.required_scopes.issubset(set(ctx.token.scopes)) + + def missing_scopes(self, ctx: AuthContext) -> set[str]: + if self.tag not in ctx.component.tags or ctx.token is None: + return set() + return set(self.required_scopes) - set(ctx.token.scopes) + + +class _RequireRoles: + """Callable auth check requiring all of a fixed set of roles. + + Deliberately not a :class:`_ScopeAwareCheck`. Roles are not scopes and + cannot be requested through OAuth, so a shortfall has no spec-correct + step-up representation. + """ + + def __init__( + self, + roles: tuple[str, ...], + extract: Callable[[dict[str, Any]], Iterable[str]], + ) -> None: + self.required_roles: frozenset[str] = frozenset(roles) + self._extract = extract + + def __call__(self, ctx: AuthContext) -> bool: + if ctx.token is None: + return False + try: + extracted = self._extract(ctx.token.claims) + # A provider that stores a single role as a bare string satisfies + # `Iterable[str]`, but iterating it yields characters: "admin" + # would deny an "admin" requirement and grant an "a" one. Treat a + # string as the single role it plainly means. + if isinstance(extracted, str): + extracted = [extracted] + granted = set(extracted) + except (KeyError, IndexError, TypeError): + # A caller whose token simply lacks the claim is an ordinary + # denial, not a broken check: return False rather than letting + # `_evaluate_check` log a warning on every unauthorized request. + return False + return self.required_roles.issubset(granted) + + +def require_scopes(*scopes: str) -> AuthCheck: + """Require all of the given OAuth scopes.""" + return _RequireScopes(scopes) + + +def require_roles( + *roles: str, + extract: Callable[[dict[str, Any]], Iterable[str]], +) -> AuthCheck: + """Require all of the given roles, read from the token's claims. + + Roles and groups are not part of OIDC, so every identity provider puts them + somewhere different: `realm_access.roles` on Keycloak, `roles` on Microsoft + Entra, `cognito:groups` on AWS Cognito, `permissions` or a namespaced custom + claim on Auth0. `extract` receives the token's claims and returns the + caller's roles, which keeps that provider-specific knowledge at the call + site instead of guessing it here. + + ```python + from fastmcp.server.auth import require_roles + + keycloak = require_roles("admin", extract=lambda c: c["realm_access"]["roles"]) + cognito = require_roles("admins", extract=lambda c: c["cognito:groups"]) + ``` + + A token missing the claim entirely is denied rather than treated as an + error, so `extract` may index into the claims without guarding. An + extractor returning a bare string is treated as one role, since a provider + that stores a single role as a scalar is common. + + Unlike `require_scopes`, this check cannot signal a shortfall: OAuth has no + way to request a role, so there is no `insufficient_scope` challenge to + emit. A role denial is therefore reported as a plain `AuthorizationError`, + and it suppresses any scope shortfall alongside it — a caller blocked by + their role must not be told to go obtain a scope that would not help. + Scope shortfalls are still reported normally whenever the role check + passes. + + Args: + *roles: Roles the caller must hold. All are required (AND logic). + extract: Callable mapping the token's claims to the caller's roles. + + Raises: + ValueError: If no roles are given, which would allow any authenticated + caller and is more likely a mistake than an intent. + """ + if not roles: + raise ValueError( + "require_roles() needs at least one role; a check with no roles " + "would admit any authenticated caller." + ) + return _RequireRoles(roles, extract) def restrict_tag(tag: str, *, scopes: list[str]) -> AuthCheck: """Require scopes when the accessed component has a specific tag.""" - required = set(scopes) + return _RestrictTag(tag, scopes) - def check(ctx: AuthContext) -> bool: - if tag not in ctx.component.tags: - return True - if ctx.token is None: - return False - return required.issubset(set(ctx.token.scopes)) - return check +def scope_requirements( + checks: AuthCheck | list[AuthCheck], + ctx: AuthContext, +) -> list[str] | None: + """Scopes a check list requires but the token lacks, without running it. + + Returns ``None`` when the list contains any opaque (non-scope) check. Such a + check might deny for a reason unrelated to scopes, and evaluating it here + would run authorization logic — with whatever side effects it carries — + outside its normal place in the chain. Since its verdict is unknown, its + siblings' scopes must not be disclosed either, so the whole list is withheld. + + When every check is scope-aware, the result is their combined shortfall, + computed purely from the token and component (an empty list means the list is + already satisfied). This lets a shortfall be aggregated across authorization + layers without evaluating anything that would otherwise be skipped. + """ + check_list = [checks] if not isinstance(checks, list) else checks + check_list = cast(list[AuthCheck], check_list) + + missing: set[str] = set() + for check in check_list: + if not isinstance(check, _ScopeAwareCheck): + return None + missing |= check.missing_scopes(ctx) + return sorted(missing) + + +async def _evaluate_check(check: AuthCheck, ctx: AuthContext) -> bool: + """Evaluate a single auth check, masking unexpected failures as denial. + + An ``AuthorizationError`` is the check's deliberate denial and propagates. + Any other exception is a bug in the check; it is logged and treated as a + denial so a broken check fails closed. + """ + try: + result = check(ctx) + if inspect.isawaitable(result): + result = await result + except AuthorizationError: + raise + except Exception: + logger.warning( + f"Auth check {getattr(check, '__name__', repr(check))} " + "raised an unexpected exception", + exc_info=True, + ) + return False + return bool(result) + + +async def run_auth_checks_with_shortfall( + checks: AuthCheck | list[AuthCheck], + ctx: AuthContext, +) -> tuple[bool, list[str]]: + """Run auth checks with AND logic, classifying the denial cause. + + Returns ``(authorized, missing_scopes)``. ``missing_scopes`` names every + scope the caller must obtain to satisfy *all* scope requirements at once: + the union of the shortfalls across every scope-aware check, not just the + first one to fail. Reporting only the first would strand a caller in a + step-up loop — it obtains that scope, retries, and is denied again for the + next — so the union is what makes a single re-authorization converge. + + The challenge is withheld entirely (an empty list, which the caller surfaces + as a plain ``AuthorizationError``) unless every non-scope check passes. A + custom policy denial — a tenant check, say — must never be reported as an + ``insufficient_scope`` shortfall, and must never name the scopes of a + component the caller could not otherwise reach. To guarantee that, the + opaque checks are all evaluated before any scope is disclosed; a shortfall + is only reported once they have all passed. + + An ``AuthorizationError`` raised by a check propagates unchanged. + """ + check_list = [checks] if not isinstance(checks, list) else checks + check_list = cast(list[AuthCheck], check_list) + + scope_shortfall = False + for check in check_list: + if await _evaluate_check(check, ctx): + continue + if not isinstance(check, _ScopeAwareCheck): + # An opaque denial dominates: deny without disclosing any scope. + return False, [] + # Keep going. The remaining opaque checks still have to pass before a + # scope shortfall may be disclosed, and the remaining scope checks + # contribute to the union. + scope_shortfall = True + + if not scope_shortfall: + return True, [] + + # Every opaque check passed, so naming the shortfall is safe. `missing_scopes` + # is a pure comparison against the token and returns an empty set for checks + # that passed, so unioning across all of them yields exactly the unmet scopes. + missing: set[str] = set() + for check in check_list: + if isinstance(check, _ScopeAwareCheck): + missing |= check.missing_scopes(ctx) + return False, sorted(missing) async def run_auth_checks( checks: AuthCheck | list[AuthCheck], ctx: AuthContext, ) -> bool: - """Run auth checks with AND logic.""" + """Run auth checks with AND logic, stopping at the first failure.""" check_list = [checks] if not isinstance(checks, list) else checks check_list = cast(list[AuthCheck], check_list) for check in check_list: - try: - result = check(ctx) - if inspect.isawaitable(result): - result = await result - if not result: - return False - except AuthorizationError: - raise - except Exception: - logger.warning( - f"Auth check {getattr(check, '__name__', repr(check))} " - "raised an unexpected exception", - exc_info=True, - ) + if not await _evaluate_check(check, ctx): return False return True diff --git a/fastmcp_slim/fastmcp/utilities/inspect.py b/fastmcp_slim/fastmcp/utilities/inspect.py index 1ce403643..2348f3410 100644 --- a/fastmcp_slim/fastmcp/utilities/inspect.py +++ b/fastmcp_slim/fastmcp/utilities/inspect.py @@ -391,10 +391,12 @@ async def inspect_fastmcp_v1(mcp: SDKServer) -> FastMCPInfo: # SDK v2's MCPServer (FastMCP 1.x) exposes name/instructions/version # directly; the v1 `_mcp_server` low-level wrapper attribute is gone. + # It defaults `version` to an empty string rather than None, so report + # an unset version as absent instead of blank. return FastMCPInfo( name=mcp.name, instructions=mcp.instructions, - version=mcp.version, + version=mcp.version or None, website_url=server_website_url, icons=server_icons, fastmcp_version=fastmcp.__version__, # Version generating this manifest diff --git a/fastmcp_slim/fastmcp/utilities/json_schema.py b/fastmcp_slim/fastmcp/utilities/json_schema.py index f8996c2f4..59715e8ed 100644 --- a/fastmcp_slim/fastmcp/utilities/json_schema.py +++ b/fastmcp_slim/fastmcp/utilities/json_schema.py @@ -1,12 +1,45 @@ from __future__ import annotations -import copy from collections import defaultdict from typing import Any from jsonref import JsonRefError, replace_refs +def _copy_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Return a deep copy of a JSON schema without recursing. + + `copy.deepcopy` consumes stack frames in proportion to nesting depth, so a + deeply nested schema raises `RecursionError` before the traversals in this + module can apply their own depth guards — turning a schema that used to + compress into one that fails outright. Schemas are plain JSON, so an + explicit stack copies the containers at any depth and shares the immutable + scalars at the leaves. + """ + root: dict[str, Any] = {} + stack: list[tuple[Any, Any]] = [(schema, root)] + + while stack: + source, target = stack.pop() + if isinstance(source, dict): + pairs: list[tuple[Any, Any]] = list(source.items()) + else: + pairs = list(enumerate(source)) + + for key, value in pairs: + if isinstance(value, dict): + child: Any = {} + stack.append((value, child)) + elif isinstance(value, list): + child = [None] * len(value) + stack.append((value, child)) + else: + child = value + target[key] = child + + return root + + def _defs_have_cycles(defs: dict[str, Any]) -> bool: """Check whether any definitions in ``$defs`` form a reference cycle. @@ -348,7 +381,7 @@ def _prune_param(schema: dict[str, Any], param: str) -> dict[str, Any]: """Return a new schema with *param* removed from `properties`, `required`, and (if no longer referenced) `$defs`. """ - schema = copy.deepcopy(schema) + schema = _copy_schema(schema) # ── 1. drop from properties/required ────────────────────────────── props = schema.get("properties", {}) @@ -501,7 +534,7 @@ def _single_pass_optimize( # Work on a copy so the caller's schema is never mutated (see docstring). The # pruning phases below pop keys/$defs in place, which would otherwise corrupt a # shared dict such as a live Tool.input_schema passed straight to compress_schema. - schema = copy.deepcopy(schema) + schema = _copy_schema(schema) # Phase 1: Collect references and apply simple cleanups # Track which $defs are referenced from the main schema and from other $defs @@ -511,6 +544,11 @@ def _single_pass_optimize( ) # def A references def B defs = schema.get("$defs") + # Set when the traversal below gives up at its depth limit. Once that + # happens the reference scan is incomplete, so we can no longer tell which + # definitions are genuinely unused. + reference_scan_truncated = False + def traverse_and_clean( node: object, current_def_name: str | None = None, @@ -528,7 +566,10 @@ def _single_pass_optimize( about) but we skip all cleanups so we don't mutate user data that happens to look metadata-shaped. """ + nonlocal reference_scan_truncated + if depth > 50: # Prevent infinite recursion + reference_scan_truncated = True return if isinstance(node, dict): @@ -652,6 +693,13 @@ def _single_pass_optimize( for def_name, def_schema in defs.items(): traverse_and_clean(def_schema, current_def_name=def_name, in_schema=True) + # An incomplete scan has not seen every $ref, so a definition that looks + # unused may simply be referenced below the cutoff. Keeping an unused + # definition is harmless; dropping a referenced one leaves a dangling + # $ref and an invalid schema. + if reference_scan_truncated: + return schema + # Phase 4: Remove unused definitions def is_def_used(def_name: str, visiting: set[str] | None = None) -> bool: """Check if a definition is used, handling circular references.""" diff --git a/fastmcp_slim/fastmcp/utilities/openapi/parser.py b/fastmcp_slim/fastmcp/utilities/openapi/parser.py index 7ad94a93d..e492a4c5a 100644 --- a/fastmcp_slim/fastmcp/utilities/openapi/parser.py +++ b/fastmcp_slim/fastmcp/utilities/openapi/parser.py @@ -36,6 +36,7 @@ from .models import ( ) from .schemas import ( _combine_schemas_and_map_params, + _discriminator_target_name, _replace_ref_with_defs, ) @@ -543,6 +544,7 @@ class OpenAPIParser( schema: dict, all_schemas: dict[str, Any], collected: set[str] | None = None, + follow_discriminator: bool = False, ) -> set[str]: """ Extract all schema names referenced by a schema (including transitive dependencies). @@ -551,6 +553,10 @@ class OpenAPIParser( schema: The schema to analyze all_schemas: All available schema definitions collected: Set of already collected schema names (for recursion) + follow_discriminator: Also collect the subtypes named by a + `discriminator.mapping`. Those values are bare strings rather + than `$ref` objects, so they are invisible to ordinary ref + collection. Returns: Set of schema names that are referenced @@ -558,6 +564,12 @@ class OpenAPIParser( if collected is None: collected = set() + def collect(schema_name: str) -> None: + """Collect a schema by name and recurse into its dependencies.""" + if schema_name not in collected and schema_name in all_schemas: + collected.add(schema_name) + find_refs(all_schemas[schema_name]) + def find_refs(obj): """Recursively find all $ref references.""" if isinstance(obj, dict): @@ -570,14 +582,19 @@ class OpenAPIParser( return # Add this schema and recursively find its dependencies - if ( - collected is not None - and schema_name not in collected - and schema_name in all_schemas - ): - collected.add(schema_name) - # Recursively find dependencies of this schema - find_refs(all_schemas[schema_name]) + collect(schema_name) + + if follow_discriminator: + discriminator = obj.get("discriminator") + if isinstance(discriminator, dict): + mapping = discriminator.get("mapping") + if isinstance(mapping, dict): + for target in mapping.values(): + if not isinstance(target, str): + continue + name = _discriminator_target_name(target) + if name: + collect(name) # Continue searching in all values for value in obj.values(): @@ -614,10 +631,15 @@ class OpenAPIParser( deps = self._extract_schema_dependencies(param.schema_, all_schemas) needed_schemas.update(deps) - # Check request body for schema references + # Check request body for schema references. Request bodies are flattened + # into a single object, so discriminated subtypes need to come along. if request_body and request_body.content_schema: for content_schema in request_body.content_schema.values(): - deps = self._extract_schema_dependencies(content_schema, all_schemas) + deps = self._extract_schema_dependencies( + content_schema, + all_schemas, + follow_discriminator=True, + ) needed_schemas.update(deps) # Return only the needed input schemas diff --git a/fastmcp_slim/fastmcp/utilities/openapi/schemas.py b/fastmcp_slim/fastmcp/utilities/openapi/schemas.py index 5980621c2..c8fe95b9b 100644 --- a/fastmcp_slim/fastmcp/utilities/openapi/schemas.py +++ b/fastmcp_slim/fastmcp/utilities/openapi/schemas.py @@ -221,6 +221,155 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]: return schema +def _allof_members( + schema: dict[str, Any], + schema_defs: dict[str, Any], + resolving: set[str] | None = None, +) -> list[dict[str, Any]]: + """Expand local schema references while collecting ``allOf`` members.""" + resolving = resolving or set() + + ref = schema.get("$ref") + if isinstance(ref, str): + for prefix in ("#/$defs/", "#/components/schemas/"): + if ref.startswith(prefix): + name = ref.removeprefix(prefix) + referenced_schema = schema_defs.get(name) + if isinstance(referenced_schema, dict) and name not in resolving: + siblings = { + key: value for key, value in schema.items() if key != "$ref" + } + members = _allof_members( + referenced_schema, schema_defs, resolving | {name} + ) + return members + ([siblings] if siblings else []) + break + + all_of = schema.get("allOf") + if isinstance(all_of, list): + members = [] + for member in all_of: + if isinstance(member, dict): + members.extend(_allof_members(member, schema_defs, resolving)) + + siblings = {key: value for key, value in schema.items() if key != "allOf"} + return members + ([siblings] if siblings else []) + + return [schema] + + +def _discriminator_target_name(target: str) -> str | None: + """Resolve a ``discriminator.mapping`` value to a local schema name. + + Mapping values hold "schema names or references", so a bare ``"Cat"`` means + the ``Cat`` component just as ``"#/components/schemas/Cat"`` does. Anything + else — a remote URL, a pointer outside the component schemas — has no local + definition to flatten. + """ + for prefix in ("#/$defs/", "#/components/schemas/"): + if target.startswith(prefix): + return target.removeprefix(prefix) or None + if target.startswith("#") or "/" in target: + return None + return target or None + + +def _flatten_discriminator_subtypes( + schema: dict[str, Any], + schema_defs: dict[str, Any], +) -> dict[str, Any] | None: + """Flatten the subtypes named by an OpenAPI ``discriminator.mapping``. + + A parent schema carrying a discriminator describes its children only + through ``mapping``, so the child fields are unreachable from the parent's + own ``properties``. Rather than emitting a branch per subtype, the fields + are merged in as optional and the variants are spelled out on the + discriminator property's description. Top-level ``oneOf`` is filled in + poorly by LLM tool-calling APIs, and the upstream API remains the real + validator either way: a field from the wrong variant is rejected there + rather than locally. + + Only the mapping on *schema* itself is expanded. A subtype carrying its own + discriminator is left alone, which also keeps the parent/child reference + cycle from recursing. + + Returns replacement ``properties`` for *schema*, or None when there is no + usable discriminator mapping to flatten. + """ + discriminator = schema.get("discriminator") + if not isinstance(discriminator, dict): + return None + + property_name = discriminator.get("propertyName") + mapping = discriminator.get("mapping") + if not isinstance(property_name, str) or not isinstance(mapping, dict): + return None + + own_props = schema.get("properties", {}) + # Variants that disagree about a property are unioned rather than resolved: + # keeping whichever came first would advertise one variant's constraint + # (a `const` tag, say) while claiming to accept all of them. + alternatives: dict[str, list[Any]] = {} + values: list[str] = [] + variants: list[str] = [] + + for value, target in mapping.items(): + if not isinstance(target, str): + continue + + name = _discriminator_target_name(target) + subtype = schema_defs.get(name) if name else None + if not isinstance(subtype, dict): + continue + + # Fields the parent already declares are shared, not variant-specific. + variant_fields: list[str] = [] + for member in _allof_members(subtype, schema_defs): + for prop_name, prop_schema in member.get("properties", {}).items(): + if prop_name in own_props: + continue + if prop_name not in variant_fields: + variant_fields.append(prop_name) + seen = alternatives.setdefault(prop_name, []) + if prop_schema not in seen: + seen.append(prop_schema) + + values.append(repr(value)) + if variant_fields: + variants.append(f"{value!r} uses {', '.join(variant_fields)}") + + # Every resolved variant is a legal tag even when it adds no fields of its + # own, so the accepted values are worth advertising on their own. + if not values: + return None + + subtype_props = { + prop_name: schemas[0] if len(schemas) == 1 else {"anyOf": schemas} + for prop_name, schemas in alternatives.items() + } + properties = {**own_props, **subtype_props} + + note = f"Selects the variant. Accepted values: {', '.join(values)}." + if variants: + note += ( + f" {'; '.join(variants)}." + " Send only the fields belonging to the selected variant." + ) + + # A discriminator names a property of the payload, so give it a schema even + # when the parent left it undeclared — it is otherwise required and unusable. + tag_schema = properties.get(property_name) + if not isinstance(tag_schema, dict): + tag_schema = {"type": "string"} + existing = tag_schema.get("description") + properties[property_name] = { + **tag_schema, + "description": f"{existing} {note}" if existing else note, + } + + return properties + + def _combine_schemas_and_map_params( route: HTTPRoute, convert_refs: bool = True, @@ -273,14 +422,13 @@ def _combine_schemas_and_map_params( merged_props = {} merged_required = [] - for sub_schema in body_schema["allOf"]: - if isinstance(sub_schema, dict): - # Merge properties - if "properties" in sub_schema: - merged_props.update(sub_schema["properties"]) - # Merge required fields - if "required" in sub_schema: - merged_required.extend(sub_schema["required"]) + for sub_schema in _allof_members(body_schema, route.request_schemas): + # Merge properties + if "properties" in sub_schema: + merged_props.update(sub_schema["properties"]) + # Merge required fields + if "required" in sub_schema: + merged_required.extend(sub_schema["required"]) # Update body_schema with merged properties body_schema["properties"] = merged_props @@ -293,6 +441,17 @@ def _combine_schemas_and_map_params( # Remove the allOf since we've merged it body_schema.pop("allOf", None) + # Merge discriminated subtype fields in as optional. The discriminator + # itself is dropped: its mapping points at definitions that are pruned + # from $defs once nothing references them, which would leave the + # emitted schema with dangling refs. + flattened_props = _flatten_discriminator_subtypes( + body_schema, route.request_schemas + ) + if flattened_props is not None: + body_schema["properties"] = flattened_props + body_schema.pop("discriminator", None) + body_props = body_schema.get("properties", {}) # Detect collisions: parameters that exist in multiple non-body locations diff --git a/fastmcp_slim/fastmcp/utilities/types.py b/fastmcp_slim/fastmcp/utilities/types.py index 47d132e63..ea2024d85 100644 --- a/fastmcp_slim/fastmcp/utilities/types.py +++ b/fastmcp_slim/fastmcp/utilities/types.py @@ -335,18 +335,21 @@ class Audio: def _get_mime_type(self) -> str: """Get MIME type from format or guess from file extension.""" + mapping = { + "wav": "audio/wav", + "mp3": "audio/mpeg", + "ogg": "audio/ogg", + "m4a": "audio/mp4", + "flac": "audio/flac", + } + if self._format: - return f"audio/{self._format.lower()}" + return mapping.get(self._format.lower(), f"audio/{self._format.lower()}") if self.path: - suffix = self.path.suffix.lower() - return { - ".wav": "audio/wav", - ".mp3": "audio/mpeg", - ".ogg": "audio/ogg", - ".m4a": "audio/mp4", - ".flac": "audio/flac", - }.get(suffix, "application/octet-stream") + return mapping.get( + self.path.suffix.lower().lstrip("."), "application/octet-stream" + ) return "audio/wav" # default for raw binary data def to_audio_content( diff --git a/fastmcp_slim/pyproject.toml b/fastmcp_slim/pyproject.toml index e23dfca8c..ba408404d 100644 --- a/fastmcp_slim/pyproject.toml +++ b/fastmcp_slim/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version", "optional-dependencies"] description = "The dependency-slim FastMCP package." authors = [{ name = "Jeremiah Lowin" }] dependencies = [ - "mcp-types==2.0.0b2", + "mcp-types>=2.0.0,<3.0.0", "platformdirs>=4.0.0", "pydantic[email]>=2.12.0", "pydantic-settings>=2.0.0", @@ -82,7 +82,7 @@ mcp = [ # client auth) requires it, and all FastMCP-owned HTTP (server auth provider # upstream calls, OpenAPI provider, version check, etc.) uses it too. "httpx2>=2.5.0", - "mcp==2.0.0b2", + "mcp>=2.0.0,<3.0.0", "opentelemetry-api>=1.28.0", # starlette floor: transitive via mcp (which only requires >=0.27). # Pin past CVE-2026-48710, which was patched in 1.0.1. diff --git a/fastmcp_tasks/fastmcp_tasks/extension.py b/fastmcp_tasks/fastmcp_tasks/extension.py index bc34daaa8..7ba4f123d 100644 --- a/fastmcp_tasks/fastmcp_tasks/extension.py +++ b/fastmcp_tasks/fastmcp_tasks/extension.py @@ -32,10 +32,12 @@ from typing import TYPE_CHECKING, Any from mcp.server.context import ServerRequestContext from mcp.shared.exceptions import MCPError +from mcp.shared.inbound import MCP_NAME_HEADER, decode_header_value +from mcp_types.jsonrpc import HEADER_MISMATCH from mcp_types.version import MODERN_PROTOCOL_VERSIONS from fastmcp.exceptions import NotFoundError -from fastmcp.server.dependencies import extract_version_spec +from fastmcp.server.dependencies import extract_version_spec, get_http_request from fastmcp.server.extensions import ( MethodBinding, ServerExtension, @@ -140,7 +142,7 @@ class TasksExtension(ServerExtension): """Reject a task method from a client that did not declare the extension. SEP-2663: a client issuing `tasks/get`/`tasks/update`/`tasks/cancel` - without the tasks capability in the request's `_meta` gets -32003. A + without the tasks capability in the request's `_meta` gets -32021. A client normally only holds a taskId because it declared the capability on the creating `tools/call`, but the method-level check is an explicit MUST, so enforce it here rather than assume. @@ -156,22 +158,56 @@ class TasksExtension(ServerExtension): data=missing_capability_error_data(), ) + def _require_matching_task_route(self, task_id: str) -> None: + """Reject a task method whose `Mcp-Name` header disagrees with its body. + + SEP-2243 mirrors a request's name-shaped field into `Mcp-Name` so + intermediaries can route without parsing the body, and requires servers + that read the body to check the two agree. SEP-2663 extends that to the + tasks namespace, where the name-shaped field is `taskId`. The core SDK's + pre-dispatch ladder only knows the base protocol's name-bearing methods, + so the extension enforces its own. + """ + try: + request = get_http_request() + except RuntimeError: + # Not an HTTP transport, so there are no routing headers to check. + return + header = request.headers.get(MCP_NAME_HEADER) + if header is None: + return + if decode_header_value(header) != task_id: + raise MCPError( + code=HEADER_MISMATCH, + message=( + f"{MCP_NAME_HEADER} header does not match the request body's " + "'taskId' parameter" + ), + ) + + def _check_task_request( + self, ctx: ServerRequestContext[Any, Any], task_id: str + ) -> None: + """Run both gates every `tasks/*` method shares.""" + self._require_tasks_capability(ctx) + self._require_matching_task_route(task_id) + async def _handle_get( self, ctx: ServerRequestContext[Any, Any], params: GetTaskParams ) -> GetTaskResult: - self._require_tasks_capability(ctx) + self._check_task_request(ctx, params.task_id) return await tasks_get(self.server, params.task_id) async def _handle_update( self, ctx: ServerRequestContext[Any, Any], params: UpdateTaskParams ) -> UpdateTaskResult: - self._require_tasks_capability(ctx) + self._check_task_request(ctx, params.task_id) return await tasks_update(self.server, params.task_id, params.input_responses) async def _handle_cancel( self, ctx: ServerRequestContext[Any, Any], params: CancelTaskParams ) -> CancelTaskResult: - self._require_tasks_capability(ctx) + self._check_task_request(ctx, params.task_id) return await tasks_cancel(self.server, params.task_id) async def intercept_tool_call( @@ -183,7 +219,7 @@ class TasksExtension(ServerExtension): """Decide whether to run this ``tools/call`` as a task. Consults the tool's ``TaskConfig`` mode and the client's per-request - opt-in: ``required`` always tasks (raising -32003 if the client did not + opt-in: ``required`` always tasks (raising -32021 if the client did not opt in), ``optional`` tasks only when the client opted in, ``forbidden`` never tasks. A non-task call passes straight through to the tool body. """ diff --git a/fastmcp_tasks/fastmcp_tasks/handlers.py b/fastmcp_tasks/fastmcp_tasks/handlers.py index b12aabf19..5193d5b84 100644 --- a/fastmcp_tasks/fastmcp_tasks/handlers.py +++ b/fastmcp_tasks/fastmcp_tasks/handlers.py @@ -39,9 +39,9 @@ from fastmcp_tasks.creation import ( registered_component_for_key, ) from fastmcp_tasks.input_store import ( - acquire_update_lock, acquire_update_lock_blocking, clear_outstanding, + discard_outstanding, is_cancelled, load_current_leg, load_task_args, @@ -296,18 +296,26 @@ async def tasks_get(server: FastMCP, task_id: str) -> GetTaskResult: if execution.state == ExecutionState.FAILED: message = "Task failed" + error: dict[str, Any] = { + "code": mcp_types.INTERNAL_ERROR, + "message": message, + } try: await execution.get_result(timeout=timedelta(seconds=0)) # On a FAILED execution, get_result re-raises the exception the task # itself raised — an arbitrary user-defined type, so no narrower catch - # exists. Its message becomes the task's error payload. - except Exception as error: - message = str(error) - return build( - "failed", - status_message=message, - error={"code": mcp_types.INTERNAL_ERROR, "message": message}, - ) + # exists. Its message becomes the task's error payload; an MCPError + # already *is* a JSON-RPC error, so its code and data are preserved + # rather than flattened to an internal error. + except MCPError as protocol_error: + message = protocol_error.error.message + error = {"code": protocol_error.error.code, "message": message} + if protocol_error.error.data is not None: + error["data"] = protocol_error.error.data + except Exception as unexpected: + message = str(unexpected) + error = {"code": mcp_types.INTERNAL_ERROR, "message": message} + return build("failed", status_message=message, error=error) if execution.state == ExecutionState.CANCELLED: return build("cancelled") @@ -342,9 +350,24 @@ async def tasks_update( ) # Serialize concurrent updates for this task so two racing answers cannot - # each enqueue a next leg (double execution). A loser is an idempotent no-op. - if not await acquire_update_lock(docket, task_scope, task_id): - return UpdateTaskResult() + # each enqueue a next leg (double execution). Waiting rather than dropping + # the loser matters for partial fulfillment: SEP-2663 invites a client to + # answer a multi-request ask one key at a time, so two in-flight updates may + # carry *different* answers. Acknowledging the loser without storing its + # answer would strand the task waiting on a key the client believes it has + # already sent. Once the winner finishes, the loser re-reads the leg's + # outstanding state: a genuinely duplicate answer finds nothing left to + # match and is the idempotent no-op SEP-2663 asks for. + if not await acquire_update_lock_blocking(docket, task_scope, task_id): + # The holder is wedged. Report a retryable failure rather than a false + # acknowledgement, which would silently lose this answer. + raise MCPError( + code=mcp_types.INTERNAL_ERROR, + message=( + f"Task {task_id} has an update in progress that did not complete " + "in time; retry this update." + ), + ) try: # A cancelled task never re-enters: clearing outstanding on cancel makes # translate return None already, but check explicitly so a cancel that @@ -352,21 +375,45 @@ async def tasks_update( if await is_cancelled(docket, task_scope, task_id): return UpdateTaskResult() - translated = await translate_responses( + matched = await translate_responses( docket, task_scope, task_id, leg_number, input_responses ) - if translated is None: + if matched is None: # Nothing matched the current leg's outstanding requests: the leg was # already answered, or the keys are unknown. Idempotent no-op. return UpdateTaskResult() + translated, answered_keys = matched - # Store the answers for the next leg to read, then enqueue that leg. - # Ordering matters: the answers must be in Redis before the next leg's - # worker context loads them, and current_leg must not advance to an - # execution that is not yet durable — so enqueue (with its durable wait) - # precedes the pointer swap. + # Store the answers for the next leg to read. They accumulate: a client + # may answer a multi-request ask one update at a time. await store_input_responses(docket, task_scope, task_id, translated) + # A partial update retires only the keys it answered, leaving the task + # `input_required` with the rest surfaced; the leg re-enters only once + # every request has an answer (SEP-2663 partial fulfillment). + # + # The *last* answer deliberately leaves its marker in place. Outstanding + # requests are what make a completed-but-parked leg read as + # `input_required` rather than as a finished task, so retiring the final + # one before the next leg is durable would let a racing `tasks/get` + # report the task complete with a `None` result — and would strand it + # there for good if the enqueue below failed, since a retried update + # would no longer match any key. `clear_outstanding` runs after the + # pointer swap instead. + outstanding = await read_outstanding_inputs( + docket, task_scope, task_id, leg_number + ) + if set(outstanding) - set(answered_keys): + await discard_outstanding( + docket, task_scope, task_id, leg_number, answered_keys + ) + return UpdateTaskResult() + + # Every request is answered, so enqueue the next leg. Ordering matters: + # the answers must be in Redis before the next leg's worker context + # loads them, and current_leg must not advance to an execution that is + # not yet durable — so enqueue (with its durable wait) precedes the + # pointer swap. component = await registered_component_for_key( server, parse_task_key(base_task_key)["component_identifier"] ) diff --git a/fastmcp_tasks/fastmcp_tasks/input_loop.py b/fastmcp_tasks/fastmcp_tasks/input_loop.py index 07145d5bf..f1f146c09 100644 --- a/fastmcp_tasks/fastmcp_tasks/input_loop.py +++ b/fastmcp_tasks/fastmcp_tasks/input_loop.py @@ -31,6 +31,7 @@ import logging from typing import TYPE_CHECKING, Any import mcp_types +from mcp.shared.exceptions import MCPError from fastmcp.exceptions import FastMCPError from fastmcp.tools.base import InputRequiredToolResult, ToolResult @@ -142,6 +143,15 @@ def reentrant_task_fn( result = await fn(*args, **kwargs) except FastMCPError as exc: return _error_result(tool_name, exc) + except MCPError: + # A protocol fault, not a tool error. SEP-2663 reserves `failed` + # for exactly this, so it must escape the wrapper: the Docket + # execution fails and `tasks/get` inlines the JSON-RPC error + # instead of reporting a completed task with an `isError` result. + logger.exception( + "background task tool %r raised a protocol error", tool_name + ) + raise except Exception as exc: logger.exception("background task tool %r raised", tool_name) return _error_result(tool_name, exc) diff --git a/fastmcp_tasks/fastmcp_tasks/input_store.py b/fastmcp_tasks/fastmcp_tasks/input_store.py index d13a6e82e..9dab545ea 100644 --- a/fastmcp_tasks/fastmcp_tasks/input_store.py +++ b/fastmcp_tasks/fastmcp_tasks/input_store.py @@ -37,6 +37,8 @@ import mcp_types from fastmcp_tasks.keys import task_redis_prefix if TYPE_CHECKING: + from collections.abc import Iterable + from docket import Docket logger = logging.getLogger(__name__) @@ -250,6 +252,11 @@ async def store_outstanding( await redis.hset(map_key, surfaced, tool_key) await redis.expire(requests_key, ttl_seconds) await redis.expire(map_key, ttl_seconds) + # The answers that drove this leg have been consumed by the body that + # just parked, so drop them: responses accumulate per leg (a client may + # answer a multi-request ask one key at a time), and a stale carry-over + # would make the next leg look already-answered. + await redis.delete(_input_responses_key(docket, task_scope, task_id)) if request_state is not None: await redis.set(state_key, request_state, ex=ttl_seconds) else: @@ -306,7 +313,7 @@ async def translate_responses( task_id: str, leg: int, responses: dict[str, Any], -) -> dict[str, mcp_types.Result] | None: +) -> tuple[dict[str, mcp_types.Result], list[str]] | None: """Translate a ``tasks/update`` payload into typed, tool-keyed responses. ``responses`` is keyed by the surfaced keys the client received for ``leg``. @@ -314,6 +321,10 @@ async def translate_responses( answer is validated into the result type its request maps to and re-keyed to the tool's own request key. Returns ``None`` when nothing matched, so the caller can treat a stale or empty update as an idempotent no-op. + + Returns the tool-keyed answers alongside the surfaced keys they came from, + so the caller can retire exactly the answered requests and leave the rest + outstanding. """ outstanding = await read_outstanding_inputs(docket, task_scope, task_id, leg) if not outstanding: @@ -321,6 +332,7 @@ async def translate_responses( mapping = await _read_outstanding_map(docket, task_scope, task_id, leg) translated: dict[str, mcp_types.Result] = {} + matched: list[str] = [] for surfaced_key, raw in responses.items(): payload = outstanding.get(surfaced_key) if payload is None: @@ -331,8 +343,11 @@ async def translate_responses( method = payload.get("method", "elicitation/create") result_type = result_type_for_method(method) translated[tool_key] = result_type.model_validate(raw) + matched.append(surfaced_key) - return translated or None + if not translated: + return None + return translated, matched async def store_input_responses( @@ -347,6 +362,11 @@ async def store_input_responses( The responses are stored typed-but-serialized (``{"type", "data"}``) so the next leg's context factory reconstructs real result objects keyed by the tool's own request keys. + + Answers merge into whatever the leg has already collected: a client may + answer a multi-request ask one `tasks/update` at a time, and the leg only + re-enters once every request has been answered. Callers hold the per-task + update lock, so the read-modify-write cannot interleave. """ stored = { tool_key: { @@ -355,12 +375,38 @@ async def store_input_responses( } for tool_key, result in translated.items() } + responses_key = _input_responses_key(docket, task_scope, task_id) async with docket.redis() as redis: - await redis.set( - _input_responses_key(docket, task_scope, task_id), - json.dumps(stored), - ex=ttl_seconds, - ) + existing_raw = _decode(await redis.get(responses_key)) + if existing_raw: + try: + existing = json.loads(existing_raw) + except json.JSONDecodeError: + existing = {} + if isinstance(existing, dict): + stored = {**existing, **stored} + await redis.set(responses_key, json.dumps(stored), ex=ttl_seconds) + + +async def discard_outstanding( + docket: Docket, + task_scope: str | None, + task_id: str, + leg: int, + surfaced_keys: Iterable[str], +) -> None: + """Drop just the surfaced keys an update answered, keeping the rest pending. + + Partial fulfillment (SEP-2663): a leg that asked several questions stays + ``input_required`` until all are answered, and each ``tasks/get`` in between + must surface only the still-unanswered keys. + """ + keys = list(surfaced_keys) + if not keys: + return + async with docket.redis() as redis: + await redis.hdel(_requests_key(docket, task_scope, task_id, leg), *keys) + await redis.hdel(_map_key(docket, task_scope, task_id, leg), *keys) async def clear_outstanding( diff --git a/fastmcp_tasks/fastmcp_tasks/models.py b/fastmcp_tasks/fastmcp_tasks/models.py index 761b7ebf4..a58fde3a1 100644 --- a/fastmcp_tasks/fastmcp_tasks/models.py +++ b/fastmcp_tasks/fastmcp_tasks/models.py @@ -22,6 +22,9 @@ from __future__ import annotations from typing import Any, Literal from mcp_types import RequestParams, Result +from mcp_types.jsonrpc import ( + MISSING_REQUIRED_CLIENT_CAPABILITY as _MISSING_REQUIRED_CLIENT_CAPABILITY, +) from pydantic import BaseModel, ConfigDict, Field __all__ = [ @@ -42,8 +45,10 @@ __all__ = [ #: JSON-RPC error code for "Missing Required Client Capability" (SEP-2663). A #: tool whose task mode is `required` returns this when the client did not opt -#: the tasks extension in for the request. -MISSING_REQUIRED_CLIENT_CAPABILITY = -32003 +#: the tasks extension in for the request, as do the `tasks/*` methods when the +#: client never negotiated the extension. Re-exported from the SDK so the code +#: tracks the protocol rather than an early draft's number. +MISSING_REQUIRED_CLIENT_CAPABILITY = _MISSING_REQUIRED_CLIENT_CAPABILITY TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"] @@ -186,7 +191,7 @@ class CancelTaskRequest(BaseModel): def missing_capability_error_data() -> dict[str, Any]: - """Build the `data.requiredCapabilities` payload for a -32003 error. + """Build the `data.requiredCapabilities` payload for a -32021 error. A `required`-mode tool called without the client opting the tasks extension in for the request returns this so the client learns which capability to diff --git a/pyproject.toml b/pyproject.toml index 0f673a848..005194822 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,11 @@ members = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks"] [tool.uv] default-groups = ["dev"] exclude-newer = "1 week" -exclude-newer-package = { fastmcp = false, fastmcp-slim = false, fastmcp-remote = false, prefab-ui = false, mcp = false, mcp-types = false, httpx2 = false, httpcore2 = false, truststore = false } +# The cooldown above refuses anything published in the last week. Exempt the +# first-party packages, whose fresh releases we install deliberately, and the +# MCP SDK, where a new major is the only version satisfying our floor and so +# has nothing older to fall back to. +exclude-newer-package = { fastmcp = false, fastmcp-slim = false, fastmcp-remote = false, prefab-ui = false, mcp = false, mcp-types = false } [dependency-groups] dev = [ diff --git a/tests/apps/test_file_upload.py b/tests/apps/test_file_upload.py index f8b65538b..e30646846 100644 --- a/tests/apps/test_file_upload.py +++ b/tests/apps/test_file_upload.py @@ -141,7 +141,9 @@ class TestFileUploadProvider: text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] assert "test.txt" in text - async def test_ui_tool_visible_backend_hidden(self): + async def test_backend_tool_listed_as_app_only(self): + """``store_files`` is listed but declares visibility=["app"], so the + host keeps it out of the model's tool list.""" server = FastMCP("test", providers=[FileUpload()]) tools = await server.list_tools() @@ -150,7 +152,11 @@ class TestFileUploadProvider: assert "file_manager" in tool_names assert "list_files" in tool_names assert "read_file" in tool_names - assert "store_files" not in tool_names + assert "store_files" in tool_names + + store_files = next(t for t in tools if t.name == "store_files") + assert store_files.meta is not None + assert store_files.meta["ui"]["visibility"] == ["app"] async def test_max_file_size_enforced_server_side(self): server = FastMCP("test", providers=[FileUpload(max_file_size=100)]) diff --git a/tests/cli/test_discovery.py b/tests/cli/test_discovery.py index 716694353..102bbf440 100644 --- a/tests/cli/test_discovery.py +++ b/tests/cli/test_discovery.py @@ -148,6 +148,40 @@ class TestParseMcpConfig: assert isinstance(servers[0].config, RemoteMCPServer) assert servers[0].config.url == "http://localhost:8000/mcp" + def test_reads_as_utf8_explicitly( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Regression test for GH-4689: config files must be read with an + explicit UTF-8 encoding, not the platform's preferred encoding + (e.g. cp949 on Windows with a non-UTF-8 locale), since that's what + every tool that writes these files emits.""" + original_read_text = Path.read_text + + def _tracking_read_text(self: Path, *args: Any, **kwargs: Any) -> str: + assert kwargs.get("encoding") == "utf-8", ( + "path.read_text() must pass encoding='utf-8' explicitly" + ) + return original_read_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", _tracking_read_text) + + path = tmp_path / "config.json" + path.write_bytes( + json.dumps( + { + "mcpServers": { + "demo": { + "command": "echo", + "args": ["hello — world"], + } + } + } + ).encode("utf-8") + ) + servers = _parse_mcp_config(path, "test") + assert len(servers) == 1 + assert servers[0].name == "demo" + # --------------------------------------------------------------------------- # Scanner: Claude Desktop diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py index d0edc194a..0db2a7fa9 100644 --- a/tests/client/client/test_client.py +++ b/tests/client/client/test_client.py @@ -18,6 +18,7 @@ from fastmcp.client.transports import ( FastMCPTransport, ) from fastmcp.server.server import FastMCP +from tests.conftest import user_meta async def test_list_tools(fastmcp_server): @@ -848,7 +849,7 @@ async def test_client_unwraps_result_using_meta(): result = await client.call_tool("list_tool", {}) assert result.structured_content == {"result": [1, 2, 3]} assert result.data == [1, 2, 3] - assert result.meta == {"fastmcp": {"wrap_result": True}} + assert user_meta(result.meta) == {"fastmcp": {"wrap_result": True}} async def test_client_does_not_unwrap_dict_result(): @@ -864,7 +865,7 @@ async def test_client_does_not_unwrap_dict_result(): result = await client.call_tool("dict_tool", {}) assert result.structured_content == {"a": 1} assert result.data == {"a": 1} - assert result.meta is None + assert user_meta(result.meta) is None async def test_client_list_dict_return_type(): diff --git a/tests/client/client/test_error_handling.py b/tests/client/client/test_error_handling.py index 7bb500d8b..96e85d56d 100644 --- a/tests/client/client/test_error_handling.py +++ b/tests/client/client/test_error_handling.py @@ -14,14 +14,13 @@ import logging import mcp_types import pytest -from mcp_types import TextContent, ToolUseContent +from mcp_types import TextContent from pydantic import AnyUrl from fastmcp.client import Client from fastmcp.client.mixins.tools import _parse_call_tool_result from fastmcp.client.transports import FastMCPTransport from fastmcp.exceptions import PromptError, ResourceError, ToolError -from fastmcp.server.sampling.run import SamplingTool, execute_tools from fastmcp.server.server import FastMCP @@ -415,38 +414,3 @@ class TestLogLevel: and record.levelname == "ERROR" for record in caplog.records ) - - async def test_sampling_tool_error_with_custom_log_level(self, caplog): - """ToolError with custom log_level in sampling should log at specified level.""" - - async def custom_level_sampling_tool(x: int) -> int: - raise ToolError("Expected sampling error", log_level=logging.WARNING) - - tool = SamplingTool.from_function(custom_level_sampling_tool) - tool_use = ToolUseContent( - type="tool_use", - id="test-id", - name="custom_level_sampling_tool", - input={"x": 42}, - ) - - with caplog.at_level(logging.WARNING): - results = await execute_tools( - tool_calls=[tool_use], - tool_map={"custom_level_sampling_tool": tool}, - mask_error_details=False, - ) - - assert len(results) == 1 - assert results[0].is_error - assert "Expected sampling error" in results[0].content[0].text # type: ignore - assert any( - "Error calling sampling tool" in record.message - and record.levelname == "WARNING" - for record in caplog.records - ) - assert not any( - "Error calling sampling tool" in record.message - and record.levelname == "ERROR" - for record in caplog.records - ) diff --git a/tests/client/client/test_mode_negotiation.py b/tests/client/client/test_mode_negotiation.py index 66904d2ee..1d400a727 100644 --- a/tests/client/client/test_mode_negotiation.py +++ b/tests/client/client/test_mode_negotiation.py @@ -244,12 +244,11 @@ class TestNonConformantModernPeer: class TestPinnedMode: async def test_pinned_modern_adopts_without_probe(self, fastmcp_server): """Pinning the modern version adopts it directly; a synthesized - DiscoverResult leaves server_info empty.""" + DiscoverResult carries no identity, so server_info is absent.""" async with Client(fastmcp_server, mode=LATEST_MODERN_VERSION) as client: assert client.protocol_version == LATEST_MODERN_VERSION assert client.initialize_result is None - assert client.server_info is not None - assert client.server_info.name == "" + assert client.server_info is None assert client.instructions is None async def test_pinned_modern_call_tool(self, fastmcp_server): diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py index d98c4199d..87c89e3a1 100644 --- a/tests/client/test_elicitation.py +++ b/tests/client/test_elicitation.py @@ -3,7 +3,6 @@ from enum import Enum from typing import Any, Literal, cast import pytest -from mcp_types import ElicitRequestFormParams, ElicitRequestParams from pydantic import BaseModel from typing_extensions import TypedDict @@ -229,29 +228,6 @@ async def test_elicitation_response_title_rejected_for_basemodel(): await client.call_tool("ask", {}) -async def test_elicitation_response_title_rejected_for_none(): - """response_title raises TypeError when response_type is None.""" - mcp = FastMCP("TestServer") - - @mcp.tool - async def ask(context: Context) -> str: - await context.elicit( - message="Confirm?", - response_type=None, - response_title="Not allowed", - ) - return "done" - - async def elicitation_handler(message, response_type, params, ctx): - return ElicitResult(action="accept", content={}) - - # Not pinned: response_title is validated locally before any request is - # dispatched, so this raises identically on every era. - async with Client(mcp, elicitation_handler=elicitation_handler) as client: - with pytest.raises(ToolError, match="response_title"): - await client.call_tool("ask", {}) - - async def test_elicitation_cancel_action(): """Test user canceling elicitation request.""" mcp = FastMCP("TestServer") @@ -301,79 +277,6 @@ class TestScalarResponseTypes: result = await client.call_tool("my_tool", {}) assert result.data == "Alice" - async def test_elicitation_no_response(self): - """Test elicitation with no response type.""" - mcp = FastMCP("TestServer") - - @mcp.tool - async def my_tool(context: Context) -> dict[str, Any]: - result = await context.elicit(message="", response_type=None) - assert isinstance(result, AcceptedElicitation) - assert isinstance(result.data, dict) - return cast(dict[str, Any], result.data) - - async def elicitation_handler( - message, response_type, params: ElicitRequestParams, ctx - ): - assert isinstance(params, ElicitRequestFormParams) - assert params.requested_schema == {"type": "object", "properties": {}} - assert response_type is None - return ElicitResult(action="accept") - - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: - result = await client.call_tool("my_tool", {}) - assert result.data is None - - async def test_elicitation_empty_response(self): - """Test elicitation with empty response type.""" - mcp = FastMCP("TestServer") - - @mcp.tool - async def my_tool(context: Context) -> dict[str, Any]: - result = await context.elicit(message="", response_type=None) - assert result.action == "accept" - assert isinstance(result, AcceptedElicitation) - accepted = result - assert isinstance(accepted.data, dict) - return accepted.data - - async def elicitation_handler( - message, response_type, params: ElicitRequestParams, ctx - ): - return ElicitResult(action="accept", content={}) - - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: - result = await client.call_tool("my_tool", {}) - assert result.data is None - - async def test_elicitation_response_when_no_response_requested(self): - """Test elicitation with no response type.""" - mcp = FastMCP("TestServer") - - @mcp.tool - async def my_tool(context: Context) -> dict[str, Any]: - result = await context.elicit(message="", response_type=None) - assert result.action == "accept" - assert isinstance(result, AcceptedElicitation) - accepted = result - assert isinstance(accepted.data, dict) - return accepted.data - - async def elicitation_handler(message, response_type, params, ctx): - return ElicitResult(action="accept", content={"value": "hello"}) - - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: - with pytest.raises( - ToolError, match="Elicitation expected an empty response" - ): - await client.call_tool("my_tool", {}) - async def test_elicitation_str_response(self): """Test elicitation with string schema.""" mcp = FastMCP("TestServer") @@ -733,6 +636,28 @@ async def test_all_primitive_field_types(): } +class TestResponseTypeRequired: + """`response_type` is required — the empty-schema form was removed in 4.0.""" + + async def test_explicit_none_raises_type_error(self): + mcp = FastMCP("TestServer") + + @mcp.tool + async def my_tool(context: Context) -> str: + await context.elicit(message="Approve?", response_type=None) # ty: ignore[no-matching-overload] + return "unreachable" + + async with Client(mcp, mode="legacy") as client: + with pytest.raises(ToolError, match="requires a response_type"): + await client.call_tool("my_tool", {}) + + async def test_omitting_response_type_raises_type_error(self): + ctx = Context(fastmcp=FastMCP("TestServer")) + + with pytest.raises(TypeError, match="response_type"): + await ctx.elicit("Approve?") # ty: ignore[no-matching-overload] + + class TestValidation: async def test_schema_validation_rejects_non_object(self): """Test that non-object schemas are rejected.""" diff --git a/tests/client/test_roots.py b/tests/client/test_roots.py index e71f0c849..10fd47632 100644 --- a/tests/client/test_roots.py +++ b/tests/client/test_roots.py @@ -1,16 +1,25 @@ +import functools + import pytest +from mcp_types import Root from fastmcp import Client, Context, FastMCP @pytest.fixture def fastmcp_server(): + """A server that issues a handshake-era `roots/list` request. + + `Context` has no `list_roots()` — server-initiated requests are not part of + FastMCP's server API. This server reaches the SDK session directly to stand + in for a legacy upstream, so the client's `roots=` handling stays covered. + """ mcp = FastMCP() @mcp.tool async def list_roots(context: Context) -> list[str]: - roots = await context.list_roots() - return [str(r.uri) for r in roots] + result = await context.session.list_roots() # ty: ignore[deprecated] + return [str(r.uri) for r in result.roots] return mcp @@ -36,10 +45,62 @@ class TestClientRoots: @pytest.mark.parametrize("roots", [["file://x/y/z", "file://x/y/z"]]) async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]): - # ctx.list_roots is a legacy-era server-initiated feature. + # `roots/list` is a server-initiated request, so it only exists on the + # handshake era; SEP-2577 removed it from the modern protocol. async with Client(fastmcp_server, mode="legacy", roots=roots) as client: result = await client.call_tool("list_roots", {}) assert result.data == [ "file://x/y/z", "file://x/y/z", ] + + async def test_roots_handler_answers_a_legacy_server(self, fastmcp_server: FastMCP): + """A callable `roots=` handler still answers a legacy server's request.""" + calls: list[object] = [] + + async def roots_handler(ctx) -> list[Root]: + calls.append(ctx) + return [Root(uri="file://from/handler")] + + async with Client(fastmcp_server, mode="legacy", roots=roots_handler) as client: + result = await client.call_tool("list_roots", {}) + + assert len(calls) == 1 + assert result.data == ["file://from/handler"] + + async def test_bound_method_roots_handler(self, fastmcp_server: FastMCP): + class RootsProvider: + async def get_roots(self, _context: object) -> list[str]: + return ["file:///bound-method"] + + provider = RootsProvider() + + async with Client( + fastmcp_server, mode="legacy", roots=provider.get_roots + ) as client: + result = await client.call_tool("list_roots", {}) + + assert result.data == ["file:///bound-method"] + + async def test_partial_roots_handler(self, fastmcp_server: FastMCP): + async def get_roots(prefix: str, _context: object) -> list[str]: + return [f"file:///{prefix}"] + + handler = functools.partial(get_roots, "partial") + + async with Client(fastmcp_server, mode="legacy", roots=handler) as client: + result = await client.call_tool("list_roots", {}) + + assert result.data == ["file:///partial"] + + async def test_callable_object_roots_handler(self, fastmcp_server: FastMCP): + class RootsProvider: + async def __call__(self, _context: object) -> list[str]: + return ["file:///callable-object"] + + async with Client( + fastmcp_server, mode="legacy", roots=RootsProvider() + ) as client: + result = await client.call_tool("list_roots", {}) + + assert result.data == ["file:///callable-object"] diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py index 98ec74494..f442419a9 100644 --- a/tests/client/test_sampling.py +++ b/tests/client/test_sampling.py @@ -1,71 +1,97 @@ import json -from typing import cast -from unittest.mock import AsyncMock +import mcp_types import pytest from mcp_types import TextContent from pydantic_core import to_json from fastmcp import Client, Context, FastMCP from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams -from fastmcp.server.sampling import SamplingResult, SamplingTool from fastmcp.utilities.types import Image +async def _sample( + context: Context, + messages: list[SamplingMessage], + *, + system_prompt: str | None = None, +) -> str: + """Issue a handshake-era `sampling/createMessage` request from a server. + + `Context` has no `sample()` — server-initiated sampling is not part of + FastMCP's server API. These tests cover the *client* side, which must keep + answering a legacy server, so the stand-in server reaches the SDK session + directly. + """ + result = await context.session.create_message( # ty: ignore[deprecated] + messages=messages, + system_prompt=system_prompt, + max_tokens=512, + related_request_id=context.origin_request_id, + ) + assert isinstance(result.content, TextContent) + return result.content.text + + @pytest.fixture def fastmcp_server(): mcp = FastMCP() @mcp.tool async def simple_sample(message: str, context: Context) -> str: - result = await context.sample("Hello, world!") - assert isinstance(result, SamplingResult) - assert result.text is not None - return result.text + return await _sample( + context, + [ + SamplingMessage( + role="user", + content=TextContent(type="text", text="Hello, world!"), + ) + ], + ) @mcp.tool async def sample_with_system_prompt(message: str, context: Context) -> str: - result = await context.sample("Hello, world!", system_prompt="You love FastMCP") - assert isinstance(result, SamplingResult) - assert result.text is not None - return result.text + return await _sample( + context, + [ + SamplingMessage( + role="user", + content=TextContent(type="text", text="Hello, world!"), + ) + ], + system_prompt="You love FastMCP", + ) @mcp.tool async def sample_with_messages(message: str, context: Context) -> str: - result = await context.sample( + return await _sample( + context, [ - "Hello!", SamplingMessage( + role="user", content=TextContent(type="text", text="Hello!") + ), + SamplingMessage( + role="assistant", content=TextContent( type="text", text="How can I assist you today?" ), - role="assistant", ), - ] + ], ) - assert isinstance(result, SamplingResult) - assert result.text is not None - return result.text @mcp.tool async def sample_with_image(image_bytes: bytes, context: Context) -> str: image = Image(data=image_bytes) - - result = await context.sample( + return await _sample( + context, [ SamplingMessage( content=TextContent(type="text", text="What's in this image?"), role="user", ), - SamplingMessage( - content=image.to_image_content(), - role="user", - ), - ] + SamplingMessage(content=image.to_image_content(), role="user"), + ], ) - assert isinstance(result, SamplingResult) - assert result.text is not None - return result.text return mcp @@ -123,26 +149,6 @@ async def test_sampling_with_messages(fastmcp_server: FastMCP): assert result.data == "I need to think." -async def test_sampling_with_fallback(fastmcp_server: FastMCP): - openai_sampling_handler = AsyncMock(return_value="But I need to think") - - fastmcp_server = FastMCP( - sampling_handler=openai_sampling_handler, - ) - - @fastmcp_server.tool - async def sample_with_fallback(context: Context) -> str: - sampling_result = await context.sample("Do not think.") - return cast(TextContent, sampling_result).text - - client = Client(fastmcp_server) - - async with client: - call_tool_result = await client.call_tool("sample_with_fallback") - - assert call_tool_result.data == "But I need to think" - - async def test_sampling_with_image(fastmcp_server: FastMCP): def sampling_handler( messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext @@ -193,8 +199,6 @@ class TestSamplingDefaultCapabilities: {"sampling": {"tools": {}}}, ensuring compatibility with servers that don't recognize the tools sub-field (e.g. older Java MCP SDK). """ - import mcp_types - server = FastMCP() def handler( @@ -209,8 +213,6 @@ class TestSamplingDefaultCapabilities: async def test_set_sampling_callback_default_capabilities_omit_tools(self): """set_sampling_callback should also default to no tools capability.""" - import mcp_types - server = FastMCP() client = Client(server) client.set_sampling_callback(lambda msgs, params, ctx: "ok") @@ -220,8 +222,6 @@ class TestSamplingDefaultCapabilities: async def test_explicit_tools_capability_is_preserved(self): """Explicitly passing tools capability should be respected.""" - import mcp_types - server = FastMCP() def handler( @@ -238,118 +238,3 @@ class TestSamplingDefaultCapabilities: caps = client._session_kwargs["sampling_capabilities"] assert isinstance(caps, mcp_types.SamplingCapability) assert caps.tools is not None - - -class TestSamplingWithTools: - """Tests for sampling with tools functionality.""" - - async def test_sampling_with_tools_requires_capability(self): - """Test that sampling with tools raises error when client lacks capability.""" - import mcp_types - - from fastmcp.exceptions import ToolError - - server = FastMCP() - - def search(query: str) -> str: - """Search the web.""" - return f"Results for: {query}" - - @server.tool - async def sample_with_tool(context: Context) -> str: - # This should fail because the client doesn't advertise tools capability - result = await context.sample( - messages="Search for Python tutorials", - tools=[search], - ) - return str(result) - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> str: - return "Response" - - # Explicitly disable tools capability by passing SamplingCapability without tools - async with Client( - server, - mode="legacy", - sampling_handler=sampling_handler, - sampling_capabilities=mcp_types.SamplingCapability(), # No tools - ) as client: - with pytest.raises(ToolError, match="sampling.tools capability"): - await client.call_tool("sample_with_tool", {}) - - async def test_sampling_with_tools_fallback_handler_can_return_string(self): - """Test that fallback handler can return a string even when tools are provided. - - The LLM might choose not to use any tools and just return a text response. - """ - # This handler returns a string - valid even when tools are provided - simple_handler = AsyncMock(return_value="Direct response without tools") - - mcp = FastMCP(sampling_handler=simple_handler) - - def search(query: str) -> str: - """Search the web.""" - return f"Results for: {query}" - - @mcp.tool - async def sample_with_tool(context: Context) -> str: - result = await context.sample( - messages="Search for Python tutorials", - tools=[search], - ) - return result.text or "no text" - - # Client without sampling handler - will use server's fallback - async with Client(mcp) as client: - result = await client.call_tool("sample_with_tool", {}) - - # Handler returned string directly, which is treated as final text response - assert result.data == "Direct response without tools" - - def test_sampling_tool_schema(self): - """Test that SamplingTool generates correct schema.""" - - def search(query: str, limit: int = 10) -> str: - """Search the web for results.""" - return f"Results for: {query}" - - tool = SamplingTool.from_function(search) - assert tool.name == "search" - assert tool.description == "Search the web for results." - assert "query" in tool.parameters.get("properties", {}) - assert "limit" in tool.parameters.get("properties", {}) - - async def test_sampling_tool_run(self): - """Test that SamplingTool.run() executes correctly.""" - - def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - - tool = SamplingTool.from_function(add) - result = await tool.run({"a": 5, "b": 3}) - assert result == 8 - - async def test_sampling_tool_run_async(self): - """Test that SamplingTool.run() works with async functions.""" - - async def async_multiply(a: int, b: int) -> int: - """Multiply two numbers.""" - return a * b - - tool = SamplingTool.from_function(async_multiply) - result = await tool.run({"a": 4, "b": 7}) - assert result == 28 - - def test_tool_choice_parameter(self): - """Test that tool_choice parameter accepts string literals.""" - from fastmcp.server.context import ToolChoiceOption - - # Verify ToolChoiceOption type accepts the valid string values - choices: list[ToolChoiceOption] = ["auto", "required", "none"] - assert len(choices) == 3 - assert "auto" in choices - assert "required" in choices - assert "none" in choices diff --git a/tests/client/test_sampling_result_types.py b/tests/client/test_sampling_result_types.py deleted file mode 100644 index e581e2582..000000000 --- a/tests/client/test_sampling_result_types.py +++ /dev/null @@ -1,681 +0,0 @@ -import pytest -from mcp_types import CreateMessageResultWithTools, TextContent, ToolUseContent - -from fastmcp import Client, Context, FastMCP -from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams - - -class TestSamplingResultType: - """Tests for result_type parameter (structured output).""" - - async def test_result_type_creates_final_response_tool(self): - """Test that result_type creates a synthetic final_response tool.""" - from mcp_types import CreateMessageResultWithTools, ToolUseContent - from pydantic import BaseModel - - class MathResult(BaseModel): - answer: int - explanation: str - - received_tools: list = [] - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - received_tools.extend(params.tools or []) - - # Return the final_response tool call - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="final_response", - input={"answer": 42, "explanation": "The meaning of life"}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def math_tool(context: Context) -> str: - result = await context.sample( - messages="What is 6 * 7?", - result_type=MathResult, - ) - # result.result should be a MathResult object - assert isinstance(result.result, MathResult) - return f"{result.result.answer}: {result.result.explanation}" - - async with Client(mcp) as client: - result = await client.call_tool("math_tool", {}) - - # Check that final_response tool was added - tool_names = [t.name for t in received_tools] - assert "final_response" in tool_names - - # Check the result - assert result.data == "42: The meaning of life" - - async def test_result_type_with_user_tools(self): - """Test result_type works alongside user-provided tools.""" - from mcp_types import CreateMessageResultWithTools, ToolUseContent - from pydantic import BaseModel - - class SearchResult(BaseModel): - summary: str - sources: list[str] - - def search(query: str) -> str: - """Search for information.""" - return f"Found info about: {query}" - - call_count = 0 - tool_was_called = False - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - nonlocal call_count, tool_was_called - call_count += 1 - - if call_count == 1: - # First call: use the search tool - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="search", - input={"query": "Python tutorials"}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - else: - # Second call: call final_response - tool_was_called = True - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_2", - name="final_response", - input={ - "summary": "Python is great", - "sources": ["python.org", "docs.python.org"], - }, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def research(context: Context) -> str: - result = await context.sample( - messages="Research Python", - tools=[search], - result_type=SearchResult, - ) - assert isinstance(result.result, SearchResult) - return f"{result.result.summary} - {len(result.result.sources)} sources" - - async with Client(mcp) as client: - result = await client.call_tool("research", {}) - - assert tool_was_called - assert result.data == "Python is great - 2 sources" - - async def test_result_type_validation_error_retries(self): - """Test that validation errors are sent back to LLM for retry.""" - from mcp_types import ( - CreateMessageResultWithTools, - ToolResultContent, - ToolUseContent, - ) - from pydantic import BaseModel - - class StrictResult(BaseModel): - value: int # Must be an int - - messages_received: list[list[SamplingMessage]] = [] - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - messages_received.append(list(messages)) - - if len(messages_received) == 1: - # First call: invalid type - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="final_response", - input={"value": "not_an_int"}, # Wrong type - ) - ], - model="test-model", - stop_reason="toolUse", - ) - else: - # Second call: valid type after seeing error - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_2", - name="final_response", - input={"value": 42}, # Correct type - ) - ], - model="test-model", - stop_reason="toolUse", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def validate_tool(context: Context) -> str: - result = await context.sample( - messages="Give me a number", - result_type=StrictResult, - ) - assert isinstance(result.result, StrictResult) - return str(result.result.value) - - async with Client(mcp) as client: - result = await client.call_tool("validate_tool", {}) - - # Should have retried after validation error - assert len(messages_received) == 2 - - # Check that error was passed back - last_messages = messages_received[1] - # Find the tool result in list content - tool_result = None - for msg in last_messages: - # Tool results are now in a list - if isinstance(msg.content, list): - for item in msg.content: - if isinstance(item, ToolResultContent): - tool_result = item - break - elif isinstance(msg.content, ToolResultContent): - tool_result = msg.content - break - assert tool_result is not None - assert tool_result.is_error is True - assert isinstance(tool_result.content[0], TextContent) - error_text = tool_result.content[0].text - assert "Validation error" in error_text - - # Final result should be correct - assert result.data == "42" - - async def test_sampling_result_has_text_and_history(self): - """Test that SamplingResult has text, result, and history attributes.""" - from mcp_types import CreateMessageResultWithTools - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Hello world")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def check_result(context: Context) -> str: - result = await context.sample(messages="Say hello") - # Check all attributes exist - assert result.text == "Hello world" - assert result.result == "Hello world" - assert len(result.history) >= 1 - return "ok" - - async with Client(mcp) as client: - result = await client.call_tool("check_result", {}) - - assert result.data == "ok" - - -class TestSampleStep: - """Tests for ctx.sample_step() - single LLM call with manual control.""" - - async def test_sample_step_basic(self): - """Test basic sample_step returns text response.""" - from mcp_types import CreateMessageResultWithTools - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Hello from step")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def test_step(context: Context) -> str: - step = await context.sample_step(messages="Hi") - assert not step.is_tool_use - assert step.text == "Hello from step" - return step.text or "" - - async with Client(mcp) as client: - result = await client.call_tool("test_step", {}) - - assert result.data == "Hello from step" - - async def test_sample_step_with_tool_execution(self): - """Test sample_step executes tools by default.""" - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - call_count = 0 - - def my_tool(x: int) -> str: - """A test tool.""" - return f"result:{x}" - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - - if call_count == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="my_tool", - input={"x": 42}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - else: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Done")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def test_step(context: Context) -> str: - messages: str | list[SamplingMessage] = "Run tool" - - while True: - step = await context.sample_step(messages=messages, tools=[my_tool]) - - if not step.is_tool_use: - return step.text or "" - - # History should include tool results when execute_tools=True - messages = step.history - - async with Client(mcp) as client: - result = await client.call_tool("test_step", {}) - - assert result.data == "Done" - assert call_count == 2 - - async def test_sample_step_execute_tools_false(self): - """Test sample_step with execute_tools=False doesn't execute tools.""" - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - tool_executed = False - - def my_tool() -> str: - """A test tool.""" - nonlocal tool_executed - tool_executed = True - return "executed" - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="my_tool", - input={}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def test_step(context: Context) -> str: - step = await context.sample_step( - messages="Run tool", - tools=[my_tool], - execute_tools=False, - ) - assert step.is_tool_use - assert len(step.tool_calls) == 1 - assert step.tool_calls[0].name == "my_tool" - # History should include assistant message but no tool results - assert len(step.history) == 2 # user + assistant - return "ok" - - async with Client(mcp) as client: - result = await client.call_tool("test_step", {}) - - assert result.data == "ok" - assert not tool_executed # Tool should not have been executed - - async def test_sample_step_history_includes_assistant_message(self): - """Test that history includes assistant message when execute_tools=False.""" - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="my_tool", - input={"query": "test"}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - def my_tool(query: str) -> str: - return f"result for {query}" - - @mcp.tool - async def test_step(context: Context) -> str: - step = await context.sample_step( - messages="Search", - tools=[my_tool], - execute_tools=False, - ) - # History should have: user message + assistant message - assert len(step.history) == 2 - assert step.history[0].role == "user" - assert step.history[1].role == "assistant" - return "ok" - - async with Client(mcp) as client: - result = await client.call_tool("test_step", {}) - - assert result.data == "ok" - - -class TestTextResponseRetry: - """Tests for retry logic when LLM returns text instead of calling final_response.""" - - @staticmethod - def _text_reply(text: str = "some text"): - from mcp_types import CreateMessageResultWithTools - - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text=text)], - model="m", - stop_reason="endTurn", - ) - - @staticmethod - def _tool_reply(value: int): - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="c1", - name="final_response", - input={"value": value}, - ) - ], - model="m", - stop_reason="toolUse", - ) - - async def test_text_response_then_success(self): - """Text on first call, final_response on second -- verify call_count == 2.""" - from pydantic import BaseModel - - class R(BaseModel): - value: int - - call_count = 0 - - def handler(messages, params, ctx): - nonlocal call_count - call_count += 1 - return self._text_reply() if call_count == 1 else self._tool_reply(42) - - mcp = FastMCP(sampling_handler=handler) - - @mcp.tool - async def t(context: Context) -> str: - return str((await context.sample(messages="q", result_type=R)).result.value) - - async with Client(mcp) as client: - result = await client.call_tool("t", {}) - - assert call_count == 2 - assert result.data == "42" - - async def test_text_response_exceeds_max_retries(self): - """Always text, never tool -- verify error after _MAX_TEXT_RESPONSE_RETRIES+1 calls.""" - from pydantic import BaseModel - - from fastmcp.exceptions import ToolError - from fastmcp.server.sampling.run import _MAX_TEXT_RESPONSE_RETRIES - - class R(BaseModel): - value: int - - call_count = 0 - - def handler(messages, params, ctx): - nonlocal call_count - call_count += 1 - return self._text_reply() - - mcp = FastMCP(sampling_handler=handler) - - @mcp.tool - async def t(context: Context) -> str: - return str((await context.sample(messages="q", result_type=R)).result) - - async with Client(mcp) as client: - with pytest.raises(ToolError, match="attempts"): - await client.call_tool("t", {}) - - assert call_count == _MAX_TEXT_RESPONSE_RETRIES + 1 - - async def test_no_retry_when_result_type_is_none(self): - """Text response with no result_type -- single call, normal return.""" - call_count = 0 - - def handler(messages, params, ctx): - nonlocal call_count - call_count += 1 - return self._text_reply("hello") - - mcp = FastMCP(sampling_handler=handler) - - @mcp.tool - async def t(context: Context) -> str: - return (await context.sample(messages="q")).text or "" - - async with Client(mcp) as client: - result = await client.call_tool("t", {}) - - assert call_count == 1 - assert result.data == "hello" - - -def _final_response(call_id: str, input_data: dict) -> CreateMessageResultWithTools: - """Build a final_response tool-use reply.""" - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", id=call_id, name="final_response", input=input_data - ) - ], - model="test-model", - stop_reason="toolUse", - ) - - -def _tool_call( - call_id: str, name: str, input_data: dict -) -> CreateMessageResultWithTools: - """Build a regular tool-use reply.""" - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent(type="tool_use", id=call_id, name=name, input=input_data) - ], - model="test-model", - stop_reason="toolUse", - ) - - -class TestValidationRetryCap: - """Tests for the consecutive validation retry cap (PR #3851).""" - - async def test_validation_failures_within_cap_then_success(self): - """Two consecutive failures followed by a valid response succeeds.""" - from pydantic import BaseModel - - class R(BaseModel): - value: int - - call_count = 0 - - def handler(messages, params, ctx): - nonlocal call_count - call_count += 1 - if call_count <= 2: - return _final_response(f"c{call_count}", {"value": "bad"}) - return _final_response(f"c{call_count}", {"value": 99}) - - mcp = FastMCP(sampling_handler=handler) - - @mcp.tool - async def t(context: Context) -> str: - r = await context.sample(messages="go", result_type=R) - return str(r.result.value) - - async with Client(mcp) as client: - result = await client.call_tool("t", {}) - - assert call_count == 3 - assert result.data == "99" - - async def test_consecutive_validation_failures_exceed_cap(self): - """Always-invalid responses raise ToolError after exceeding the cap.""" - from pydantic import BaseModel - - from fastmcp.exceptions import ToolError - from fastmcp.server.sampling.run import _MAX_VALIDATION_RETRIES - - class R(BaseModel): - value: int - - call_count = 0 - - def handler(messages, params, ctx): - nonlocal call_count - call_count += 1 - return _final_response(f"c{call_count}", {"value": "wrong"}) - - mcp = FastMCP(sampling_handler=handler) - - @mcp.tool - async def t(context: Context) -> str: - return str((await context.sample(messages="go", result_type=R)).result) - - async with Client(mcp) as client: - with pytest.raises(ToolError, match="consecutive"): - await client.call_tool("t", {}) - - # 1 initial attempt + _MAX_VALIDATION_RETRIES retries - assert call_count == _MAX_VALIDATION_RETRIES + 1 - - async def test_validation_counter_resets_after_other_tool_call(self): - """A tool call between validation failures resets the counter.""" - from pydantic import BaseModel - - class R(BaseModel): - value: int - - def helper_tool(x: int) -> str: - """A helper tool.""" - return f"result:{x}" - - call_count = 0 - - def handler(messages, params, ctx): - nonlocal call_count - call_count += 1 - # fail -> other tool (resets counter) -> fail -> succeed - if call_count == 1: - return _final_response("c1", {"value": "bad"}) - if call_count == 2: - return _tool_call("c2", "helper_tool", {"x": 1}) - if call_count == 3: - return _final_response("c3", {"value": "bad"}) - return _final_response("c4", {"value": 42}) - - mcp = FastMCP(sampling_handler=handler) - - @mcp.tool - async def t(context: Context) -> str: - r = await context.sample(messages="go", tools=[helper_tool], result_type=R) - return str(r.result.value) - - async with Client(mcp) as client: - result = await client.call_tool("t", {}) - - assert call_count == 4 - assert result.data == "42" diff --git a/tests/client/test_sampling_tool_loop.py b/tests/client/test_sampling_tool_loop.py deleted file mode 100644 index 0e18bd25f..000000000 --- a/tests/client/test_sampling_tool_loop.py +++ /dev/null @@ -1,811 +0,0 @@ -from typing import cast - -from mcp_types import TextContent - -from fastmcp import Client, Context, FastMCP -from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams -from fastmcp.server.sampling import SamplingTool - - -class TestAutomaticToolLoop: - """Tests for automatic tool execution loop in ctx.sample().""" - - async def test_automatic_tool_loop_executes_tools(self): - """Test that ctx.sample() automatically executes tool calls.""" - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - call_count = 0 - tool_was_called = False - - def get_weather(city: str) -> str: - """Get weather for a city.""" - nonlocal tool_was_called - tool_was_called = True - return f"Weather in {city}: sunny, 72°F" - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - - if call_count == 1: - # First call: return tool use - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="get_weather", - input={"city": "Seattle"}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - else: - # Second call: return final response - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="The weather is sunny!")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def weather_assistant(question: str, context: Context) -> str: - result = await context.sample( - messages=question, - tools=[get_weather], - ) - # Get text from SamplingResult - return result.text or "" - - async with Client(mcp) as client: - result = await client.call_tool( - "weather_assistant", {"question": "What's the weather?"} - ) - - assert tool_was_called - assert call_count == 2 - assert result.data == "The weather is sunny!" - - async def test_automatic_tool_loop_multiple_tools(self): - """Test that multiple tool calls in one response are all executed.""" - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - executed_tools: list[str] = [] - - def tool_a(x: int) -> int: - """Tool A.""" - executed_tools.append(f"tool_a({x})") - return x * 2 - - def tool_b(y: int) -> int: - """Tool B.""" - executed_tools.append(f"tool_b({y})") - return y + 10 - - call_count = 0 - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - - if call_count == 1: - # Return multiple tool calls - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", id="call_a", name="tool_a", input={"x": 5} - ), - ToolUseContent( - type="tool_use", id="call_b", name="tool_b", input={"y": 3} - ), - ], - model="test-model", - stop_reason="toolUse", - ) - else: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Done!")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def multi_tool(context: Context) -> str: - result = await context.sample(messages="Run tools", tools=[tool_a, tool_b]) - return result.text or "" - - async with Client(mcp) as client: - result = await client.call_tool("multi_tool", {}) - - assert executed_tools == ["tool_a(5)", "tool_b(3)"] - assert result.data == "Done!" - - async def test_automatic_tool_loop_handles_unknown_tool(self): - """Test that unknown tool names result in error being passed to LLM.""" - from mcp_types import ( - CreateMessageResultWithTools, - ToolResultContent, - ToolUseContent, - ) - - def known_tool() -> str: - """A known tool.""" - return "known result" - - messages_received: list[list[SamplingMessage]] = [] - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - messages_received.append(list(messages)) - - if len(messages_received) == 1: - # Request unknown tool - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="unknown_tool", - input={}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - else: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Handled error")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def test_unknown(context: Context) -> str: - result = await context.sample(messages="Test", tools=[known_tool]) - return result.text or "" - - async with Client(mcp) as client: - result = await client.call_tool("test_unknown", {}) - - # Check that error was passed back in messages - assert len(messages_received) == 2 - last_messages = messages_received[1] - # Find the tool result in list content - tool_result = None - for msg in last_messages: - # Tool results are now in a list - if isinstance(msg.content, list): - for item in msg.content: - if isinstance(item, ToolResultContent): - tool_result = item - break - elif isinstance(msg.content, ToolResultContent): - tool_result = msg.content - break - assert tool_result is not None - assert tool_result.is_error is True - # Content is list of TextContent objects - assert isinstance(tool_result.content[0], TextContent) - error_text = tool_result.content[0].text - assert "Unknown tool" in error_text - assert result.data == "Handled error" - - async def test_automatic_tool_loop_handles_tool_exception(self): - """Test that tool exceptions are caught and passed to LLM as errors.""" - from mcp_types import ( - CreateMessageResultWithTools, - ToolResultContent, - ToolUseContent, - ) - - def failing_tool() -> str: - """A tool that raises an exception.""" - raise ValueError("Tool failed intentionally") - - messages_received: list[list[SamplingMessage]] = [] - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - messages_received.append(list(messages)) - - if len(messages_received) == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="failing_tool", - input={}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - else: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Handled error")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def test_exception(context: Context) -> str: - result = await context.sample(messages="Test", tools=[failing_tool]) - return result.text or "" - - async with Client(mcp) as client: - result = await client.call_tool("test_exception", {}) - - # Check that error was passed back - assert len(messages_received) == 2 - last_messages = messages_received[1] - # Find the tool result in list content - tool_result = None - for msg in last_messages: - # Tool results are now in a list - if isinstance(msg.content, list): - for item in msg.content: - if isinstance(item, ToolResultContent): - tool_result = item - break - elif isinstance(msg.content, ToolResultContent): - tool_result = msg.content - break - assert tool_result is not None - assert tool_result.is_error is True - # Content is list of TextContent objects - assert isinstance(tool_result.content[0], TextContent) - error_text = tool_result.content[0].text - assert "Tool failed intentionally" in error_text - assert result.data == "Handled error" - - async def test_concurrent_tool_execution_default_sequential(self): - """Test that tools execute sequentially by default.""" - import asyncio - - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - # Ordering is guaranteed structurally (the loop awaits each tool call - # to completion before starting the next when tool_concurrency is - # None), so no real delay is needed to prove it - a single - # `asyncio.sleep(0)` still yields control to the event loop. - execution_order: list[str] = [] - - async def slow_tool_a(x: int) -> int: - """Slow tool A.""" - execution_order.append("tool_a_start") - await asyncio.sleep(0) - execution_order.append("tool_a_end") - return x * 2 - - async def slow_tool_b(y: int) -> int: - """Slow tool B.""" - execution_order.append("tool_b_start") - await asyncio.sleep(0) - execution_order.append("tool_b_end") - return y + 10 - - call_count = 0 - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - - if call_count == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_a", - name="slow_tool_a", - input={"x": 5}, - ), - ToolUseContent( - type="tool_use", - id="call_b", - name="slow_tool_b", - input={"y": 3}, - ), - ], - model="test-model", - stop_reason="toolUse", - ) - else: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Done!")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def test_tool(context: Context) -> str: - result = await context.sample( - messages="Run tools", - tools=[slow_tool_a, slow_tool_b], - # Default: tool_concurrency=None (sequential) - ) - return result.text or "" - - async with Client(mcp) as client: - result = await client.call_tool("test_tool", {}) - - assert result.data == "Done!" - # Verify sequential execution: tool_a must complete before tool_b starts - assert execution_order == [ - "tool_a_start", - "tool_a_end", - "tool_b_start", - "tool_b_end", - ] - - async def test_concurrent_tool_execution_unlimited(self): - """Test unlimited parallel tool execution with tool_concurrency=0.""" - import asyncio - - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - # tool_a blocks on an event that only tool_b sets. This is only - # satisfiable if both tools are genuinely running concurrently: under - # sequential execution tool_b would never start (tool_a would never - # finish awaiting it) and the test would fail via the wait_for - # timeout rather than racing on wall-clock timestamps. - execution_order: list[str] = [] - tool_b_started = asyncio.Event() - - async def slow_tool_a(x: int) -> int: - """Slow tool A.""" - execution_order.append("tool_a_start") - await asyncio.wait_for(tool_b_started.wait(), timeout=1.0) - execution_order.append("tool_a_end") - return x * 2 - - async def slow_tool_b(y: int) -> int: - """Slow tool B.""" - execution_order.append("tool_b_start") - tool_b_started.set() - execution_order.append("tool_b_end") - return y + 10 - - call_count = 0 - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - - if call_count == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_a", - name="slow_tool_a", - input={"x": 5}, - ), - ToolUseContent( - type="tool_use", - id="call_b", - name="slow_tool_b", - input={"y": 3}, - ), - ], - model="test-model", - stop_reason="toolUse", - ) - else: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Done!")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def test_tool(context: Context) -> str: - result = await context.sample( - messages="Run tools", - tools=[slow_tool_a, slow_tool_b], - tool_concurrency=0, # Unlimited parallel - ) - return result.text or "" - - async with Client(mcp) as client: - result = await client.call_tool("test_tool", {}) - - assert result.data == "Done!" - # Verify parallel execution: tool_b started and finished entirely - # inside tool_a's blocked wait, which is only possible if the two - # tools were running concurrently. - assert execution_order == [ - "tool_a_start", - "tool_b_start", - "tool_b_end", - "tool_a_end", - ] - - async def test_concurrent_tool_execution_bounded(self): - """Test bounded parallel execution with tool_concurrency=2.""" - import asyncio - - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - # tool_1 and tool_2 each block until *both* have started, which is - # only possible if two slots are occupied simultaneously (proving - # concurrency=2 admits two tools at once). tool_3 has no blocking - # branch, so its appearance in the log tells us when the real - # semaphore in the implementation let it in - only after a slot - # frees, i.e. after tool_1 or tool_2 finishes. - execution_order: list[str] = [] - both_started = asyncio.Event() - started_names: set[str] = set() - - async def slow_tool(name: str) -> str: - """Generic tool used to observe bounded concurrency.""" - execution_order.append(f"{name}_start") - if name in ("tool_1", "tool_2"): - started_names.add(name) - if {"tool_1", "tool_2"} <= started_names: - both_started.set() - await asyncio.wait_for(both_started.wait(), timeout=1.0) - execution_order.append(f"{name}_end") - return f"{name} done" - - call_count = 0 - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - - if call_count == 1: - # Request 3 tools (with concurrency=2, first 2 run parallel, then 3rd) - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="slow_tool", - input={"name": "tool_1"}, - ), - ToolUseContent( - type="tool_use", - id="call_2", - name="slow_tool", - input={"name": "tool_2"}, - ), - ToolUseContent( - type="tool_use", - id="call_3", - name="slow_tool", - input={"name": "tool_3"}, - ), - ], - model="test-model", - stop_reason="toolUse", - ) - else: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Done!")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def test_tool(context: Context) -> str: - result = await context.sample( - messages="Run tools", - tools=[slow_tool], - tool_concurrency=2, # Max 2 concurrent - ) - return result.text or "" - - async with Client(mcp) as client: - result = await client.call_tool("test_tool", {}) - - assert result.data == "Done!" - # Verify that at most 2 tools run concurrently - # First 2 tools should start before either ends - assert execution_order[0] in ["tool_1_start", "tool_2_start"] - assert execution_order[1] in ["tool_1_start", "tool_2_start"] - # Third tool should start after at least one of the first two finishes - tool_3_start_idx = execution_order.index("tool_3_start") - assert ( - "tool_1_end" in execution_order[:tool_3_start_idx] - or "tool_2_end" in execution_order[:tool_3_start_idx] - ) - - async def test_sequential_tool_forces_sequential_execution(self): - """Test that sequential=True forces all tools to execute sequentially.""" - import asyncio - - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - # A sequential=True tool in the batch forces the whole batch through - # the plain for-loop path (see run.py's `requires_sequential`), so - # ordering is guaranteed structurally and no real delay is needed. - execution_order: list[str] = [] - - async def normal_tool(x: int) -> int: - """Normal tool.""" - execution_order.append("normal_start") - await asyncio.sleep(0) - execution_order.append("normal_end") - return x * 2 - - async def sequential_tool(y: int) -> int: - """Sequential tool.""" - execution_order.append("sequential_start") - await asyncio.sleep(0) - execution_order.append("sequential_end") - return y + 10 - - call_count = 0 - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - - if call_count == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="normal_tool", - input={"x": 5}, - ), - ToolUseContent( - type="tool_use", - id="call_2", - name="sequential_tool", - input={"y": 3}, - ), - ], - model="test-model", - stop_reason="toolUse", - ) - else: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Done!")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def test_tool(context: Context) -> str: - # Create tools with sequential=True for one of them - normal = SamplingTool.from_function(normal_tool, sequential=False) - sequential = SamplingTool.from_function(sequential_tool, sequential=True) - - result = await context.sample( - messages="Run tools", - tools=[normal, sequential], - tool_concurrency=0, # Request unlimited, but sequential tool forces sequential - ) - return result.text or "" - - async with Client(mcp) as client: - result = await client.call_tool("test_tool", {}) - - assert result.data == "Done!" - # Verify sequential execution: first tool must complete before second starts - assert execution_order[0] in ["normal_start", "sequential_start"] - assert execution_order[1] in ["normal_end", "sequential_end"] - # Ensure the second tool starts after the first ends - if execution_order[0] == "normal_start": - assert execution_order[1] == "normal_end" - assert execution_order[2] == "sequential_start" - else: - assert execution_order[1] == "sequential_end" - assert execution_order[2] == "normal_start" - - async def test_concurrent_tool_execution_error_handling(self): - """Test that errors are captured per-tool in parallel execution.""" - from mcp_types import ( - CreateMessageResultWithTools, - ToolResultContent, - ToolUseContent, - ) - - def good_tool() -> str: - return "success" - - def bad_tool() -> str: - raise ValueError("Tool error") - - messages_received: list[list[SamplingMessage]] = [] - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - messages_received.append(list(messages)) - - if len(messages_received) == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", id="call_1", name="good_tool", input={} - ), - ToolUseContent( - type="tool_use", id="call_2", name="bad_tool", input={} - ), - ], - model="test-model", - stop_reason="toolUse", - ) - else: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Handled errors")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def test_tool(context: Context) -> str: - result = await context.sample( - messages="Run tools", - tools=[good_tool, bad_tool], - tool_concurrency=0, # Parallel execution - ) - return result.text or "" - - async with Client(mcp) as client: - result = await client.call_tool("test_tool", {}) - - assert result.data == "Handled errors" - # Check that tool results include both success and error - tool_result_message = messages_received[1][-1] - assert tool_result_message.role == "user" - tool_results = cast(list[ToolResultContent], tool_result_message.content) - assert len(tool_results) == 2 - # One should be success, one should be error - assert any(not r.is_error for r in tool_results) - assert any(r.is_error for r in tool_results) - - async def test_concurrent_tool_result_order_preserved(self): - """Test that tool results maintain the same order as tool calls.""" - import asyncio - - from mcp_types import ( - CreateMessageResultWithTools, - ToolResultContent, - ToolUseContent, - ) - - # Chain events so the tools finish in a different order (2, 3, 1) - # than they were called (1, 2, 3), without depending on real delays: - # tool 2 finishes immediately and unblocks tool 3, which finishes and - # unblocks tool 1. This only resolves if all three run concurrently - - # under sequential execution tool 1 would deadlock waiting on tool 3, - # which itself would never have been started yet. - tool_2_done = asyncio.Event() - tool_3_done = asyncio.Event() - - async def tool_with_delay(value: int) -> int: - """Tool that finishes out of call order.""" - if value == 1: - await asyncio.wait_for(tool_3_done.wait(), timeout=1.0) - elif value == 3: - await asyncio.wait_for(tool_2_done.wait(), timeout=1.0) - - if value == 2: - tool_2_done.set() - elif value == 3: - tool_3_done.set() - return value - - messages_received: list[list[SamplingMessage]] = [] - - def sampling_handler( - messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext - ) -> CreateMessageResultWithTools: - messages_received.append(list(messages)) - - if len(messages_received) == 1: - # Call order is 1, 2, 3 but they finish out of order (2, 3, 1) - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="tool_with_delay", - input={"value": 1}, - ), - ToolUseContent( - type="tool_use", - id="call_2", - name="tool_with_delay", - input={"value": 2}, - ), - ToolUseContent( - type="tool_use", - id="call_3", - name="tool_with_delay", - input={"value": 3}, - ), - ], - model="test-model", - stop_reason="toolUse", - ) - else: - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="Done!")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def test_tool(context: Context) -> str: - result = await context.sample( - messages="Run tools", - tools=[tool_with_delay], - tool_concurrency=0, # Parallel execution - ) - return result.text or "" - - async with Client(mcp) as client: - result = await client.call_tool("test_tool", {}) - - assert result.data == "Done!" - # Check that results are in the correct order (1, 2, 3) despite finishing order (2, 3, 1) - tool_result_message = messages_received[1][-1] - tool_results = cast(list[ToolResultContent], tool_result_message.content) - assert len(tool_results) == 3 - assert tool_results[0].tool_use_id == "call_1" - assert tool_results[1].tool_use_id == "call_2" - assert tool_results[2].tool_use_id == "call_3" - # Check values are correct - result_texts = [cast(TextContent, r.content[0]).text for r in tool_results] - assert result_texts == ["1", "2", "3"] diff --git a/tests/conformance/expected-failures.yml b/tests/conformance/expected-failures.yml index 46b2081de..9e3ed9dde 100644 --- a/tests/conformance/expected-failures.yml +++ b/tests/conformance/expected-failures.yml @@ -1,6 +1,23 @@ +# Scenarios the conformance suite runs that FastMCP does not pass. +# +# This is a baseline, not a to-do list: every entry needs a reason, and anything +# that is merely unimplemented in the *fixture* belongs in server.py instead. +# The suite is run with `--suite all`, so draft and pending scenarios count too. + server: - - completion-complete - - server-sse-polling + # Resource subscriptions (resources/subscribe, resources/unsubscribe) are not + # implemented. The server correctly advertises `resources.subscribe: false`, + # but the suite calls the methods regardless of the declared capability. Both + # scenarios were removed in MCP 2026-07-28, the version FastMCP targets, so + # this affects handshake-era clients only. - resources-subscribe - resources-unsubscribe - - dns-rebinding-protection + + # SEP-2663 MRTR-to-tasks composition: a task-supporting guard tool is + # expected to gather its input over foreground multi-round-trip rounds and + # only mint the task on the final round. FastMCP instead creates the task up + # front and parks it at `input_required`, answered through `tasks/update` — + # the model the `tasks-mrtr-input` scenario exercises. Supporting both would + # need the tool to declare which one it wants, which is an unmade API + # decision rather than a bug. + - tasks-mrtr-composition diff --git a/tests/conformance/server.py b/tests/conformance/server.py index 9a6a97c28..cc8a020ae 100644 --- a/tests/conformance/server.py +++ b/tests/conformance/server.py @@ -9,17 +9,33 @@ import base64 import json import sys from enum import Enum as PyEnum +from typing import Annotated import mcp_types -from mcp_types import EmbeddedResource, ImageContent, TextContent +import uvicorn +from mcp.shared.exceptions import MCPError +from mcp_types import ( + ClientCapabilities, + Completion, + EmbeddedResource, + ImageContent, + MissingRequiredClientCapabilityErrorData, + PromptReference, + TextContent, +) +from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY from pydantic import BaseModel, Field from fastmcp import FastMCP from fastmcp.exceptions import ToolError from fastmcp.prompts import Message +from fastmcp.server.completions import CompletionValues from fastmcp.server.context import Context +from fastmcp.server.event_store import EventStore from fastmcp.tools.function_tool import FunctionTool +from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import Audio, Image +from fastmcp_tasks import TasksExtension # Minimal 1x1 red PNG for image tests (89 bytes) _1X1_PNG = base64.b64decode( @@ -47,6 +63,29 @@ _SILENT_WAV = ( server = FastMCP("conformance-test-server", dereference_schemas=False) +def require_client_capability(ctx: Context, capability: str) -> None: + """Raise `-32021` unless the client declared *capability* on this request. + + SEP-2575 makes capability negotiation per-request: the client repeats its + capabilities in each request's `_meta`, and a server that needs one the + client did not declare must answer with a + `MissingRequiredClientCapabilityError` whose `data.requiredCapabilities` is + a `ClientCapabilities` object keyed by the missing capability. + """ + client_params = ctx.session.client_params + declared = client_params.capabilities if client_params else None + if declared is not None and getattr(declared, capability, None) is not None: + return + data = MissingRequiredClientCapabilityErrorData( + required_capabilities=ClientCapabilities.model_validate({capability: {}}) + ) + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message=f"Client did not declare the required {capability!r} capability", + data=data.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + + # --------------------------------------------------------------------------- # Tools # --------------------------------------------------------------------------- @@ -136,12 +175,27 @@ async def test_tool_with_progress(ctx: Context) -> str: @server.tool(name="test_sampling") async def test_sampling(prompt: str, ctx: Context) -> str: - """Requests LLM sampling via the client.""" - result = await ctx.sample( - messages=[prompt], - result_type=str, + """Requests LLM sampling via the client. + + `Context` has no `sample()` — server-initiated sampling is not part of + FastMCP's server API. The handshake-era wire path is still supported and + still shipped (the proxy relay uses it), so this fixture reaches the SDK + session directly to keep the scenario covered. + """ + result = await ctx.session.create_message( # ty: ignore[deprecated] + messages=[ + mcp_types.SamplingMessage( + role="user", + content=mcp_types.TextContent(type="text", text=prompt), + ) + ], + max_tokens=512, + related_request_id=ctx.origin_request_id, ) - return f"Sampling result: {result}" + text = ( + result.content.text if isinstance(result.content, mcp_types.TextContent) else "" + ) + return f"Sampling result: {text}" class _UserInfo(BaseModel): @@ -261,6 +315,7 @@ server.add_tool( "type": "object", "$defs": { "address": { + "$anchor": "address", "type": "object", "properties": { "street": {"type": "string"}, @@ -272,12 +327,423 @@ server.add_tool( "name": {"type": "string"}, "address": {"$ref": "#/$defs/address"}, }, + # SEP-2106 requires servers to pass composition and conditional + # keywords through to the client untouched. + "allOf": [ + { + "anyOf": [ + {"required": ["name"]}, + {"required": ["address"]}, + ] + } + ], + "if": {"required": ["address"]}, + "then": {"properties": {"name": {"minLength": 1}}}, + "else": {}, "additionalProperties": False, }, ) ) +@server.tool(name="test_reconnection") +async def test_reconnection(ctx: Context) -> str: + """Closes the POST stream mid-call so the client must resume (SEP-1699). + + The result is written after the stream is gone, so it can only reach the + client through the event store on reconnect. + """ + await ctx.report_progress(0, 100) + await ctx.close_sse_stream() + await asyncio.sleep(0.1) + return "Reconnection test complete." + + +@server.tool(name="test_custom_headers") +async def test_custom_headers( + message: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Message"})], +) -> str: + """Mirrors an argument into an `Mcp-Param-Message` header (SEP-2243). + + The annotation is what makes the header recognized; the transport compares + the header against this argument before the tool ever runs. + """ + return f"Received message: {message}" + + +@server.tool(name="test_missing_capability") +async def test_missing_capability(ctx: Context) -> str: + """Requires the client to have declared the sampling capability (SEP-2575). + + A stateless server may not rely on a capability the client did not declare + in this request's `io.modelcontextprotocol/clientCapabilities` `_meta` + block, so an undeclared caller gets `-32021` rather than a tool result. + """ + require_client_capability(ctx, "sampling") + return "Client declared the sampling capability." + + +# --------------------------------------------------------------------------- +# Multi-round-trip input requests (SEP-2322) +# +# A guard component returns an `InputRequiredResult` naming what it needs; the +# client fulfils those requests and calls again, and the answers arrive on +# `ctx.input_responses` with any `ctx.request_state` echoed back. The framework +# seals and verifies `request_state`, so a tampered echo is rejected before a +# handler sees it. +# --------------------------------------------------------------------------- + + +def _elicit_request(message: str, field: str) -> mcp_types.ElicitRequest: + """A single-field form elicitation for *field*.""" + return mcp_types.ElicitRequest( + method="elicitation/create", + params=mcp_types.ElicitRequestFormParams( + message=message, + requested_schema={ + "type": "object", + "properties": {field: {"type": "string"}}, + "required": [field], + }, + ), + ) + + +def _sampling_request(text: str, max_tokens: int) -> mcp_types.CreateMessageRequest: + """A one-message sampling request.""" + return mcp_types.CreateMessageRequest( + method="sampling/createMessage", + params=mcp_types.CreateMessageRequestParams( + messages=[ + mcp_types.SamplingMessage( + role="user", + content=TextContent(type="text", text=text), + ) + ], + max_tokens=max_tokens, + ), + ) + + +def _elicited_field(responses: mcp_types.InputResponses, key: str, field: str) -> str: + """The accepted value of *field* from the elicitation answered under *key*.""" + answer = responses[key] + if not isinstance(answer, mcp_types.ElicitResult) or answer.content is None: + return "" + return str(answer.content.get(field, "")) + + +@server.tool(name="test_input_required_result_elicitation") +async def test_input_required_result_elicitation( + ctx: Context, +) -> str | mcp_types.InputRequiredResult: + """Asks the client one elicitation question, then greets the answer. + + A retry whose `inputResponses` omit the key is re-asked rather than + errored: the answer is still missing, so the honest result is the same + request again. + """ + responses = ctx.input_responses + if responses is None or "user_name" not in responses: + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={"user_name": _elicit_request("What is your name?", "name")}, + ) + return f"Hello, {_elicited_field(responses, 'user_name', 'name')}!" + + +@server.tool(name="test_input_required_result_sampling") +async def test_input_required_result_sampling( + ctx: Context, +) -> str | mcp_types.InputRequiredResult: + """Asks the client to sample an answer, then echoes the sampled text.""" + responses = ctx.input_responses + if responses is None: + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={ + "capital_question": _sampling_request( + "What is the capital of France?", 100 + ) + }, + ) + answer = responses["capital_question"] + text = "" + if isinstance(answer, mcp_types.CreateMessageResult) and isinstance( + answer.content, TextContent + ): + text = answer.content.text + return f"Sampling result: {text}" + + +@server.tool(name="test_input_required_result_list_roots") +async def test_input_required_result_list_roots( + ctx: Context, +) -> str | mcp_types.InputRequiredResult: + """Asks the client for its roots, then reports them back.""" + responses = ctx.input_responses + if responses is None: + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={ + "client_roots": mcp_types.ListRootsRequest(method="roots/list") + }, + ) + answer = responses["client_roots"] + roots = ( + [str(root.uri) for root in answer.roots] + if isinstance(answer, mcp_types.ListRootsResult) + else [] + ) + return f"Client roots: {', '.join(roots)}" + + +@server.tool(name="test_input_required_result_request_state") +async def test_input_required_result_request_state( + ctx: Context, +) -> str | mcp_types.InputRequiredResult: + """Carries opaque state across the round trip and confirms it came back.""" + responses = ctx.input_responses + if responses is None: + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={ + "confirm": mcp_types.ElicitRequest( + method="elicitation/create", + params=mcp_types.ElicitRequestFormParams( + message="Please confirm", + requested_schema={ + "type": "object", + "properties": {"ok": {"type": "boolean"}}, + "required": ["ok"], + }, + ), + ) + }, + request_state="conformance-state-v1", + ) + if ctx.request_state != "conformance-state-v1": + raise ToolError("requestState was not echoed back intact") + return "state-ok: requestState round-tripped" + + +@server.tool(name="test_input_required_result_multiple_inputs") +async def test_input_required_result_multiple_inputs( + ctx: Context, +) -> str | mcp_types.InputRequiredResult: + """Asks for elicitation, sampling, and roots in a single round.""" + responses = ctx.input_responses + if responses is None: + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={ + "user_name": _elicit_request("What is your name?", "name"), + "greeting": _sampling_request("Generate a greeting", 50), + "client_roots": mcp_types.ListRootsRequest(method="roots/list"), + }, + request_state="conformance-multi-v1", + ) + name = _elicited_field(responses, "user_name", "name") + return f"Collected {len(responses)} responses for {name}" + + +@server.tool(name="test_input_required_result_multi_round") +async def test_input_required_result_multi_round( + ctx: Context, +) -> str | mcp_types.InputRequiredResult: + """Asks two dependent questions across three rounds.""" + responses = ctx.input_responses + if responses is None: + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={ + "step1": _elicit_request("Step 1: What is your name?", "name") + }, + request_state="round-1", + ) + if "step1" in responses: + name = _elicited_field(responses, "step1", "name") + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={ + "step2": _elicit_request( + "Step 2: What is your favorite color?", "color" + ) + }, + request_state=f"round-2:{name}", + ) + color = _elicited_field(responses, "step2", "color") + name = (ctx.request_state or "round-2:").split(":", 1)[1] + return f"{name} likes {color}" + + +@server.tool(name="test_input_required_result_tampered_state") +async def test_input_required_result_tampered_state( + ctx: Context, +) -> str | mcp_types.InputRequiredResult: + """Round-trips sealed state so a tampered echo is rejected by the framework.""" + responses = ctx.input_responses + if responses is None: + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={ + "confirm": _elicit_request("Please confirm", "confirmation") + }, + request_state="sealed-state-v1", + ) + return f"Accepted state: {ctx.request_state}" + + +@server.tool(name="test_input_required_result_capabilities") +async def test_input_required_result_capabilities( + ctx: Context, +) -> str | mcp_types.InputRequiredResult: + """Asks only for the input methods this client declared it can answer.""" + responses = ctx.input_responses + if responses is not None: + return f"Collected {len(responses)} responses" + + client_params = ctx.session.client_params + declared = client_params.capabilities if client_params else None + requests: dict[str, mcp_types.InputRequest] = {} + if declared is not None and declared.sampling is not None: + requests["capital_question"] = _sampling_request( + "What is the capital of France?", 100 + ) + if declared is not None and declared.elicitation is not None: + requests["user_name"] = _elicit_request("What is your name?", "name") + if declared is not None and declared.roots is not None: + requests["client_roots"] = mcp_types.ListRootsRequest(method="roots/list") + if not requests: + return "Client declared no input capabilities" + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests=requests, + ) + + +# --------------------------------------------------------------------------- +# Background tasks (SEP-2663) +# +# The tasks extension is what turns `task=`-declared tools into background +# work; registering it also advertises `io.modelcontextprotocol/tasks` under +# `capabilities.extensions` and gates the `tasks/*` methods on negotiation. +# The in-memory Docket backend keeps the fixture to a single process. +# --------------------------------------------------------------------------- + +server.add_extension(TasksExtension(url="memory://")) + + +@server.tool(name="greet") +async def greet(name: str) -> str: + """A sync-only tool: never runs as a task.""" + return f"Hello, {name}!" + + +@server.tool(name="slow_compute", task=True) +async def slow_compute(seconds: float = 1.0, label: str = "") -> str: + """Sleeps for *seconds*, so a cancel can land while it is still running.""" + await asyncio.sleep(seconds) + return f"Computed {label} after {seconds} seconds" + + +@server.tool(name="failing_job", task=TaskConfig(mode="required")) +async def failing_job() -> str: + """Reports a tool execution error: `completed` with `result.isError`. + + Registered `required` so a client that never negotiated the extension gets + `-32021` rather than a synchronous run. + """ + await asyncio.sleep(1) + raise ToolError("This job intentionally fails for testing") + + +@server.tool(name="protocol_error_job", task=True) +async def protocol_error_job() -> str: + """Raises a protocol-level error: `failed` with an inlined `error`.""" + raise MCPError( + code=mcp_types.INTERNAL_ERROR, + message="Protocol-level failure for testing", + ) + + +@server.tool(name="confirm_delete", task=True) +async def confirm_delete( + filename: str, ctx: Context +) -> str | mcp_types.InputRequiredResult: + """Parks the task on one elicitation before doing the (pretend) deletion.""" + responses = ctx.input_responses + if responses is None: + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={ + "confirm": _elicit_request( + f"Confirm deletion of {filename}?", "confirmation" + ) + }, + ) + answer = _elicited_field(responses, "confirm", "confirmation") + return f"Deleted {filename}: {answer}" + + +@server.tool(name="multi_input", task=True) +async def multi_input(ctx: Context) -> str | mcp_types.InputRequiredResult: + """Parks the task on two elicitations at once, so they can be answered separately.""" + responses = ctx.input_responses + if responses is None: + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={ + "first": _elicit_request("First question?", "first"), + "second": _elicit_request("Second question?", "second"), + }, + ) + first = _elicited_field(responses, "first", "first") + second = _elicited_field(responses, "second", "second") + return f"Answers: {first}, {second}" + + +@server.tool(name="test_tool_with_task", task=TaskConfig(mode="required")) +async def test_tool_with_task(ctx: Context) -> str | mcp_types.InputRequiredResult: + """Gathers input over MRTR, then escalates the final round to a task. + + The composition is the point: round 1 is a plain `InputRequiredResult` + with no `taskId`, and the round that actually does the work becomes a + `CreateTaskResult` because the tool requires task execution. + """ + responses = ctx.input_responses + if responses is None: + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={"user_name": _elicit_request("What is your name?", "name")}, + ) + return f"Task completed for {_elicited_field(responses, 'user_name', 'name')}" + + +# --------------------------------------------------------------------------- +# Completions +# --------------------------------------------------------------------------- + +_PROMPT_ARG_COMPLETIONS = ["paris", "park", "party"] + + +@server.completion +async def complete( + ref: mcp_types.PromptReference | mcp_types.ResourceTemplateReference, + argument: mcp_types.CompletionArgument, + context: mcp_types.CompletionContext | None, +) -> CompletionValues: + """Suggests values for `test_prompt_with_arguments` arguments.""" + if isinstance(ref, PromptReference) and ref.name == "test_prompt_with_arguments": + matches = [ + value + for value in _PROMPT_ARG_COMPLETIONS + if value.startswith(argument.value) + ] + return Completion(values=matches, total=len(matches), has_more=False) + return None + + # --------------------------------------------------------------------------- # Resources # --------------------------------------------------------------------------- @@ -372,6 +838,49 @@ async def test_prompt_with_image() -> list: ] +@server.prompt(name="test_input_required_result_prompt") +async def test_input_required_result_prompt( + ctx: Context, +) -> str | mcp_types.InputRequiredResult: + """A prompt that gathers its context by elicitation before rendering. + + `InputRequiredResult` is universal — it is a result type, not a tools/call + feature — so `prompts/get` can ask for input the same way a tool does. + """ + responses = ctx.input_responses + if responses is None: + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={ + "user_context": _elicit_request( + "What context should the prompt use?", "context" + ) + }, + ) + context_value = _elicited_field(responses, "user_context", "context") + return f"Prompt rendered with context: {context_value}" + + +MCP_PATH = "/mcp" + + +def build_app(): + """The ASGI app the conformance suite is run against. + + Shared by the pytest fixture and the `__main__` entry point so both exercise + the same configuration. The event store is what makes SSE resumption work, + which `test_reconnection` depends on; host/origin protection is a spec MUST + for a localhost server without TLS or auth. + """ + return server.http_app( + transport="streamable-http", + path=MCP_PATH, + host_origin_protection=True, + event_store=EventStore(), + retry_interval=100, + ) + + if __name__ == "__main__": port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000 - server.run(transport="streamable-http", host="127.0.0.1", port=port) + uvicorn.run(build_app(), host="127.0.0.1", port=port, log_level="warning") diff --git a/tests/conformance/test_conformance.py b/tests/conformance/test_conformance.py index 78749f8bc..5c17e8eb6 100644 --- a/tests/conformance/test_conformance.py +++ b/tests/conformance/test_conformance.py @@ -1,5 +1,14 @@ """Run the MCP conformance test suite against a FastMCP server. +The suite is pinned rather than tracking `@latest`: upstream adds scenarios for +draft SEPs, so an unpinned run turns CI red on somebody else's release rather +than on a change of ours. Bumping `CONFORMANCE_VERSION` is how new scenarios +arrive, and the diff shows what they cost. + +`--suite all` includes draft and pending scenarios, which is deliberate — most +of what FastMCP implements ahead of a spec release lives there. Anything that +does not pass is listed in `expected-failures.yml` with a reason. + Requires Node.js and npx to be available on PATH. Mark: pytest -m conformance """ @@ -17,7 +26,9 @@ import uvicorn CONFORMANCE_DIR = Path(__file__).parent EXPECTED_FAILURES = CONFORMANCE_DIR / "expected-failures.yml" HOST = "127.0.0.1" -MCP_PATH = "/mcp" + +#: Pinned version of `@modelcontextprotocol/conformance`. Bump deliberately. +CONFORMANCE_VERSION = "0.2.0-alpha.10" def _get_free_port() -> int: @@ -36,12 +47,10 @@ def _require_npx(): @pytest.fixture(scope="module") def conformance_server(_require_npx): """Start the conformance test server in a background thread.""" - from tests.conformance.server import server as mcp_server + from tests.conformance.server import MCP_PATH, build_app port = _get_free_port() - app = mcp_server.http_app(transport="streamable-http", path=MCP_PATH) - - config = uvicorn.Config(app, host=HOST, port=port, log_level="warning") + config = uvicorn.Config(build_app(), host=HOST, port=port, log_level="warning") uv_server = uvicorn.Server(config) thread = threading.Thread(target=uv_server.run, daemon=True) @@ -66,13 +75,13 @@ def conformance_server(_require_npx): @pytest.mark.conformance -@pytest.mark.timeout(120) +@pytest.mark.timeout(180) def test_mcp_conformance(conformance_server): """Run the full MCP conformance test suite against the server.""" cmd = [ "npx", "--yes", - "@modelcontextprotocol/conformance@latest", + f"@modelcontextprotocol/conformance@{CONFORMANCE_VERSION}", "server", "--url", conformance_server, @@ -83,7 +92,7 @@ def test_mcp_conformance(conformance_server): if EXPECTED_FAILURES.exists(): cmd.extend(["--expected-failures", str(EXPECTED_FAILURES)]) - result = subprocess.run(cmd, capture_output=True, text=True, timeout=90) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=150) # Print output for visibility in test results if result.stdout: diff --git a/tests/conftest.py b/tests/conftest.py index 84487e445..8e2fb96c7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any import pytest +from mcp_types import SERVER_INFO_META_KEY from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor @@ -23,6 +24,21 @@ if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) +def user_meta(meta: dict[str, Any] | None) -> dict[str, Any] | None: + """Strip the SDK's `serverInfo` stamp from a result's `_meta`. + + Every 2026-era result carries `io.modelcontextprotocol/serverInfo` (spec + #3002), stamped by the SDK runner rather than by the component that + produced the result. Tests asserting on the meta a tool or resource set + itself use this to ignore the stamp, and get `None` back when the stamp was + the only entry. + """ + if meta is None: + return None + remaining = {k: v for k, v in meta.items() if k != SERVER_INFO_META_KEY} + return remaining or None + + def make_server_request_context( *, method: str = "tools/list", diff --git a/tests/deprecated/test_elicitation.py b/tests/deprecated/test_elicitation.py deleted file mode 100644 index 187c5404d..000000000 --- a/tests/deprecated/test_elicitation.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Tests for deprecated elicitation behavior.""" - -from typing import Any, cast - -import pytest - -from fastmcp import Context, FastMCP -from fastmcp.client.client import Client -from fastmcp.client.elicitation import ElicitResult -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.elicitation import AcceptedElicitation - - -async def test_elicitation_none_response_type_warns_deprecation(): - """Passing response_type=None is deprecated — warn at call time.""" - mcp = FastMCP("TestServer") - - @mcp.tool - async def my_tool(context: Context) -> dict[str, Any]: - with pytest.warns(FastMCPDeprecationWarning, match="response_type"): - result = await context.elicit(message="", response_type=None) - assert isinstance(result, AcceptedElicitation) - return cast(dict[str, Any], result.data) - - async def elicitation_handler(message, response_type, params, ctx): - return ElicitResult(action="accept", content={}) - - # `ctx.elicit` sends a server-initiated request down the client's - # back-channel, which only the older protocol has, so this pins that era. - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: - await client.call_tool("my_tool", {}) diff --git a/tests/docs/test_upgrade_guide_api_claims.py b/tests/docs/test_upgrade_guide_api_claims.py new file mode 100644 index 000000000..c580a4380 --- /dev/null +++ b/tests/docs/test_upgrade_guide_api_claims.py @@ -0,0 +1,250 @@ +"""Check the API claims the upgrade guides make against the real APIs. + +The other two doc tests cover code blocks: one executes them, one compares the +before/after pair. Neither looks at *prose*, and prose is where a migration +guide does most of its work — mapping tables, prompt checklists, and sentences +naming an attribute to use. Those claims went wrong repeatedly and in the same +way: an API was named without anyone checking it resolved. + +So this file checks the claims mechanically: + +- every ``ctx.`` the guides tell a reader to *use* exists on the class + they'd be using it on, and every one they name as removed really is gone +- every ``MCPServer`` constructor parameter appears somewhere in the SDK v2 + guide, so a newly added SDK argument can't quietly go unmapped +- the ``request_context`` attributes the guides route people to are real + +Run: + uv run pytest tests/docs/test_upgrade_guide_api_claims.py -v +""" + +from __future__ import annotations + +import inspect +import re +import warnings +from pathlib import Path +from typing import Any + +import pytest + +UPGRADE_DIR = Path("docs/getting-started/upgrading") + + +def _guide(name: str) -> str: + return (UPGRADE_DIR / name).read_text("utf-8") + + +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + from mcp.server.mcpserver import MCPServer + + from fastmcp import Context as FastMCPContext + + +# Context attributes the guides may mention without them existing on FastMCP's +# Context, because the guide's whole point is that they are gone or moved. Each +# is asserted to genuinely be absent, so a name that later gains an +# implementation stops being listed as missing. +DOCUMENTED_AS_ABSENT = { + "sample", + "sample_step", + "list_roots", + "mcp_server", + "headers", + "protocol_version", + "client_capabilities", + "elicit_url", + "close_standalone_sse_stream", + "notify_tools_changed", + "notify_resources_changed", + "notify_prompts_changed", + "notify_resource_updated", + "params", + "meta", +} + + +def test_absent_context_attributes_are_really_absent(): + """Names the guides describe as gone must not exist on FastMCP's Context. + + If one of these gains an implementation, the guides are now telling people + to work around something that works, and this test says so. + """ + resurrected = [ + n for n in sorted(DOCUMENTED_AS_ABSENT) if hasattr(FastMCPContext, n) + ] + assert not resurrected, ( + f"guides describe these as absent from fastmcp.Context, but they exist: {resurrected}" + ) + + +@pytest.mark.parametrize( + "guide", + sorted(p.name for p in UPGRADE_DIR.glob("*.mdx")), +) +def test_ctx_attributes_named_in_guides_exist(guide: str): + """Every ``ctx.`` in a guide either exists or is documented as absent.""" + referenced = set(re.findall(r"`ctx\.([a-z_]+)", _guide(guide))) + unknown = { + name + for name in referenced + if not hasattr(FastMCPContext, name) and name not in DOCUMENTED_AS_ABSENT + } + assert not unknown, ( + f"{guide} names ctx.{{{', '.join(sorted(unknown))}}}, which do not exist on " + f"fastmcp.Context and are not in DOCUMENTED_AS_ABSENT" + ) + + +def test_request_context_attributes_the_guides_route_to_exist(): + """The guides send people to ``ctx.request_context`` for several attributes. + + ``FastMCPRequestContext`` resolves its attributes dynamically, so this is + checked against a live request rather than the class. + """ + import asyncio + + from fastmcp import Client, FastMCP + + mcp = FastMCP("probe") + + @mcp.tool + async def probe(ctx: FastMCPContext) -> list[str]: + rc = ctx.request_context + return [n for n in ("request_id", "meta", "protocol_version") if hasattr(rc, n)] + + async def run() -> list[str]: + async with Client(mcp) as client: + return (await client.call_tool("probe", {})).data + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + present = asyncio.run(run()) + + assert set(present) == {"request_id", "meta", "protocol_version"} + + +# Context methods the SDK v2 guide says are *genuinely* unchanged. Existence is +# not enough for that claim — a method present on both classes with a different +# signature is worse than a missing one, because the import swap compiles and +# fails at runtime. So these are compared signature-for-signature. +CLAIMED_SIGNATURE_COMPATIBLE = ["report_progress"] + +# Present on both, but with signatures that differ. The guide must describe each +# migration rather than list it as carrying over; this pins the difference so a +# future SDK or FastMCP release that converges them shows up as a failure. +KNOWN_SIGNATURE_DIFFERENCES = ["log", "info", "debug", "warning", "error", "elicit"] + + +@pytest.mark.parametrize("method", CLAIMED_SIGNATURE_COMPATIBLE) +def test_methods_claimed_unchanged_have_identical_signatures(method: str): + from mcp.server.mcpserver import Context as SDKContext + + sdk = inspect.signature(getattr(SDKContext, method)) + fastmcp = inspect.signature(getattr(FastMCPContext, method)) + assert str(sdk) == str(fastmcp), ( + f"the SDK v2 guide lists ctx.{method} as carrying over unchanged, but " + f"the signatures differ:\n SDK : {sdk}\n FastMCP: {fastmcp}" + ) + + +@pytest.mark.parametrize("method", KNOWN_SIGNATURE_DIFFERENCES) +def test_methods_with_known_signature_differences_still_differ(method: str): + from mcp.server.mcpserver import Context as SDKContext + + sdk = inspect.signature(getattr(SDKContext, method)) + fastmcp = inspect.signature(getattr(FastMCPContext, method)) + assert str(sdk) != str(fastmcp), ( + f"ctx.{method} signatures now match; the guide's migration note for it " + f"is stale and should be moved to the unchanged list" + ) + + +# SDK v1's `mcp.server.fastmcp.FastMCP.__init__` parameters. Hardcoded because +# v1 cannot be installed alongside v4 to introspect — read from the published +# mcp 1.20.0 wheel. Anything here that FastMCP 4 does not accept must appear in +# the v1 guide, since a reader following "it's one import change" hits it. +SDK_V1_CONSTRUCTOR_PARAMS = [ + "name", "instructions", "website_url", "icons", "auth_server_provider", + "token_verifier", "event_store", "tools", "debug", "log_level", "host", + "port", "mount_path", "sse_path", "message_path", "streamable_http_path", + "json_response", "stateless_http", "warn_on_duplicate_resources", + "warn_on_duplicate_tools", "warn_on_duplicate_prompts", "dependencies", + "lifespan", "auth", "transport_security", "transport", +] # fmt: skip + + +def test_sdk_v1_constructor_params_fastmcp_rejects_are_documented(): + """Every v1 keyword FastMCP 4 refuses must be named in the v1 guide. + + The guide's headline is that upgrading is a single import change. That is + only honest if the constructor arguments it *doesn't* accept are spelled + out, so nobody follows the headline into a ``TypeError``. + """ + from fastmcp import FastMCP + + guide = _guide("from-mcp-sdk-v1.mdx") + probe: dict[str, Any] = { + "name": "s", + "icons": None, + "tools": None, + "lifespan": None, + } + + undocumented = [] + for param in SDK_V1_CONSTRUCTOR_PARAMS: + if param == "name": + continue + kwargs: dict[str, Any] = {param: probe.get(param)} + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + FastMCP("s", **kwargs) + continue # accepted, nothing to document + except TypeError: + pass + except Exception: + continue # accepted the keyword, rejected the probe value + shorthand = param.replace("warn_on_duplicate", "") + if re.search(rf"`{re.escape(param)}[=`]", guide): + continue + if param.startswith("warn_on_duplicate") and re.search( + rf"`{re.escape(shorthand)}[=`]", guide + ): + continue + undocumented.append(param) + + assert not undocumented, ( + "SDK v1 FastMCP() parameters that FastMCP 4 rejects but from-mcp-sdk-v1.mdx " + f"never mentions: {undocumented}" + ) + + +def test_every_mcpserver_constructor_param_is_mapped(): + """The SDK v2 guide claims an exhaustive constructor mapping — hold it to that. + + A parameter added to ``MCPServer`` upstream should fail here rather than + reach a reader as an unmapped keyword that raises ``TypeError`` on FastMCP. + """ + guide = _guide("from-mcp-sdk-v2.mdx") + params = [ + p for p in inspect.signature(MCPServer.__init__).parameters if p != "self" + ] + + unmapped = [] + for param in params: + # `warn_on_duplicate_resources` is covered by the table's shorthand + # "warn_on_duplicate_tools, _resources, _prompts". + shorthand = param.replace("warn_on_duplicate", "") + if re.search(rf"`{re.escape(param)}[=`]", guide): + continue + if param.startswith("warn_on_duplicate") and re.search( + rf"`{re.escape(shorthand)}`", guide + ): + continue + unmapped.append(param) + + assert not unmapped, ( + f"MCPServer constructor parameters not mentioned in from-mcp-sdk-v2.mdx: {unmapped}" + ) diff --git a/tests/docs/test_upgrade_guide_equivalence.py b/tests/docs/test_upgrade_guide_equivalence.py new file mode 100644 index 000000000..0549ff228 --- /dev/null +++ b/tests/docs/test_upgrade_guide_equivalence.py @@ -0,0 +1,239 @@ +"""Prove the SDK v2 upgrade guides produce an equivalent server. + +`test_upgrade_guide_examples.py` proves every example runs. That is necessary +but not sufficient: a migration guide is only correct if the "after" code +exposes the same MCP surface as the "before" code it replaces. A guide whose +halves both run but disagree on a tool's schema teaches a silent regression. + +So for each MCP SDK v2 guide, the complete before-and-after server pair is +lifted out of the page, both halves are built, and their advertised tools, +resources, templates, and prompts are compared. The SDK v1 guides are not +covered here — v1 is not installable alongside v4, so their "before" code +cannot be built to compare against. + +Run: + uv run pytest tests/docs/test_upgrade_guide_equivalence.py -v +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import pytest +from pytest_examples.find_examples import _extract_code_chunks + +from fastmcp import Client, FastMCP + +UPGRADE_DIR = Path("docs/getting-started/upgrading") + + +def _block_containing(page: str, needle: str) -> dict[str, Any]: + """Execute the one code block on `page` that contains `needle`.""" + path = UPGRADE_DIR / page + matches = [ + ex + for ex in _extract_code_chunks(path, path.read_text("utf-8"), uuid4()) + if needle in ex.source + ] + assert len(matches) == 1, ( + f"expected exactly one block in {page} containing {needle!r}, " + f"found {len(matches)}" + ) + namespace: dict[str, Any] = {"__name__": "fastmcp_docs_example"} + exec(compile(matches[0].source, str(path), "exec"), namespace) + return namespace + + +def _strip_titles(node: Any) -> Any: + """Recursively drop every "title" key, the one difference that's genuinely cosmetic. + + A hand-written SDK schema has no title anywhere; FastMCP derives one at every + level from the function/model it built the schema from. Everything else in the + tree — constraints, "additionalProperties", nested "anyOf"/"const", enum values — + is retained, because those describe what a client is allowed to send and a + silent difference there is exactly the kind of regression this test exists to + catch. + """ + if isinstance(node, dict): + return {k: _strip_titles(v) for k, v in node.items() if k != "title"} + if isinstance(node, list): + return [_strip_titles(v) for v in node] + return node + + +def _normalize(schema: dict[str, Any] | None) -> dict[str, Any]: + """Compare schemas by their full structure, modulo generated titles. + + "required" is sorted because the SDK and FastMCP may build it in a different + parameter order for the same signature — an ordering difference, not a + contract difference. + """ + if not schema: + return {} + stripped = _strip_titles(schema) + if "required" in stripped: + stripped["required"] = sorted(stripped["required"]) + return stripped + + +def _split_declared_strictness( + before: dict[str, dict[str, Any]], after: dict[str, dict[str, Any]] +) -> dict[str, dict[str, Any]]: + """Pop `"additionalProperties": false` from every migrated schema, asserting it's there. + + FastMCP's generated tool schemas declare `"additionalProperties": false`; a + schema built by either SDK server API does not declare it. This is a real + contract change, not a cosmetic one — both SDK APIs *accept* an unexpected + argument at call time, and FastMCP rejects it (pinned by + `test_fastmcp_tightens_the_argument_contract` in each class below). It is + popped here only so the rest of the schema can be compared field for field, + and popping is an assertion rather than a silent discard: if FastMCP ever + stops declaring it, or the SDK starts, this fails. + """ + stripped: dict[str, dict[str, Any]] = {} + for name, schema in after.items(): + schema = dict(schema) + assert schema.pop("additionalProperties", None) is False, ( + f"expected FastMCP to declare additionalProperties: false for {name!r}" + ) + assert "additionalProperties" not in before.get(name, {}), ( + f"expected the SDK schema for {name!r} not to declare additionalProperties" + ) + stripped[name] = schema + return stripped + + +async def _fastmcp_surface(mcp: FastMCP) -> dict[str, Any]: + async with Client(mcp) as client: + tools = await client.list_tools() + resources = await client.list_resources() + templates = await client.list_resource_templates() + prompts = await client.list_prompts() + return { + "tools": {t.name: _normalize(t.input_schema) for t in tools}, + "resources": {str(r.uri) for r in resources}, + "templates": {t.uri_template for t in templates}, + "prompts": {p.name: sorted(a.name for a in p.arguments or []) for p in prompts}, + } + + +class TestMCPServerGuide: + """docs/.../from-mcp-sdk-v2.mdx — the high-level MCPServer migration.""" + + @pytest.fixture(scope="class") + def pair(self) -> tuple[Any, FastMCP]: + before = _block_containing("from-mcp-sdk-v2.mdx", 'MCPServer("demo")') + after = _block_containing("from-mcp-sdk-v2.mdx", 'FastMCP("demo")') + return before["server"], after["mcp"] + + async def test_same_surface(self, pair): + server, mcp = pair + + before = { + "tools": { + t.name: _normalize(t.input_schema) for t in await server.list_tools() + }, + "resources": {str(r.uri) for r in await server.list_resources()}, + "templates": { + t.uri_template for t in await server.list_resource_templates() + }, + "prompts": { + p.name: sorted(a.name for a in p.arguments or []) + for p in await server.list_prompts() + }, + } + + after = await _fastmcp_surface(mcp) + after["tools"] = _split_declared_strictness(before["tools"], after["tools"]) + assert before == after + + async def test_fastmcp_tightens_the_argument_contract(self, pair): + """FastMCP rejects an unexpected argument where MCPServer accepts it. + + This is the behavior behind the `additionalProperties` schema difference, + and it is a real change for any caller that was passing extra keys. + """ + server, mcp = pair + + tolerated = await server.call_tool( + "greet", {"name": "World", "extra": "surprise"} + ) + assert tolerated.is_error is False + assert tolerated.content[0].text == "Hello, World!" + + async with Client(mcp) as client: + with pytest.raises(Exception): + await client.call_tool("greet", {"name": "World", "extra": "surprise"}) + + async def test_migrated_tools_still_work(self, pair): + _, mcp = pair + async with Client(mcp) as client: + greeting = await client.call_tool("greet", {"name": "World"}) + processed = await client.call_tool("process", {"items": ["a", "b"]}) + + assert greeting.data == "Hello, World!" + assert processed.data == "Processed 2 items" + + +class TestLowLevelGuide: + """docs/.../from-low-level-sdk-v2.mdx — the low-level Server migration.""" + + @pytest.fixture(scope="class") + def pair(self) -> tuple[dict[str, Any], FastMCP]: + before = _block_containing("from-low-level-sdk-v2.mdx", ' "demo",') + after = _block_containing("from-low-level-sdk-v2.mdx", 'FastMCP("demo")') + return before, after["mcp"] + + async def test_same_surface(self, pair): + handlers, mcp = pair + + tools = await handlers["list_tools"](None, None) + resources = await handlers["list_resources"](None, None) + prompts = await handlers["list_prompts"](None, None) + before = { + "tools": {t.name: _normalize(t.input_schema) for t in tools.tools}, + "resources": {str(r.uri) for r in resources.resources}, + "templates": set(), + "prompts": { + p.name: sorted(a.name for a in p.arguments or []) + for p in prompts.prompts + }, + } + + after = await _fastmcp_surface(mcp) + after["tools"] = _split_declared_strictness(before["tools"], after["tools"]) + assert before == after + + async def test_fastmcp_tightens_the_argument_contract(self, pair): + """FastMCP rejects an unexpected argument where the handler ignored it. + + A low-level handler reads `params.arguments` as a plain dict and never + looks at keys it doesn't need, so extras pass through silently. The + migrated tool rejects them. Pinned rather than normalized away, because + it is a real change for any caller that was passing extra keys. + """ + handlers, mcp = pair + params = type( + "Params", (), {"name": "greet", "arguments": {"name": "World", "extra": 1}} + )() + + tolerated = await handlers["call_tool"](None, params) + assert tolerated.content[0].text == "Hello, World!" + + async with Client(mcp) as client: + with pytest.raises(Exception): + await client.call_tool("greet", {"name": "World", "extra": 1}) + + async def test_handlers_and_tools_agree(self, pair): + """The rewritten tool returns what the hand-written handler returned.""" + handlers, mcp = pair + params = type("Params", (), {"name": "greet", "arguments": {"name": "World"}})() + + handler_result = await handlers["call_tool"](None, params) + async with Client(mcp) as client: + tool_result = await client.call_tool("greet", {"name": "World"}) + + assert handler_result.content[0].text == "Hello, World!" + assert tool_result.data == "Hello, World!" diff --git a/tests/docs/test_upgrade_guide_examples.py b/tests/docs/test_upgrade_guide_examples.py new file mode 100644 index 000000000..142c662b4 --- /dev/null +++ b/tests/docs/test_upgrade_guide_examples.py @@ -0,0 +1,101 @@ +"""Execute the Python examples in the upgrade guides. + +`test_doc_examples.py` covers every page in `docs/`, but only checks that +examples parse and that their ``fastmcp.*`` imports resolve. The upgrade guides +carry a stronger obligation: someone lands on one mid-migration, copies a block, +and runs it. So these examples are actually executed, and their non-FastMCP +imports (`mcp`, `mcp_types`) are exercised along with everything else. + +Both halves of a `` are executed where they can be. The "after" code +is FastMCP 4, which this repo is. The "before" code is only runnable when it +targets the MCP SDK **v2** — the version installed here — which covers the two +SDK v2 guides. Blocks written against SDK v1 (whose `mcp.types` and +`mcp.server.fastmcp` no longer exist) and fragments that pair a "# Before" and +"# After" in one block are tagged ``test="skip"`` in the source and skipped here; +the count of those is pinned so a new one can't appear unnoticed. + +Run: + uv run pytest tests/docs/test_upgrade_guide_examples.py -v +""" + +from __future__ import annotations + +import warnings +from pathlib import Path +from uuid import uuid4 + +import pytest +from pytest_examples import CodeExample +from pytest_examples.find_examples import _extract_code_chunks + +import fastmcp + +UPGRADE_DIR = Path("docs/getting-started/upgrading") + +# Blocks deliberately not executable: SDK v1 API that is no longer installable, +# and before/after fragments that are not standalone programs. Pinned so that +# adding a skip is a visible decision rather than a silent one. +EXPECTED_SKIPS = 35 + + +def _examples() -> list[CodeExample]: + examples: list[CodeExample] = [] + for mdx_file in sorted(UPGRADE_DIR.rglob("*.mdx")): + code = mdx_file.read_text("utf-8") + examples.extend(_extract_code_chunks(mdx_file, code, uuid4())) + return examples + + +ALL = _examples() +RUNNABLE = [ex for ex in ALL if ex.prefix_settings().get("test") != "skip"] +SKIPPED = [ex for ex in ALL if ex.prefix_settings().get("test") == "skip"] + + +def _example_id(example: CodeExample) -> str: + return f"{Path(example.path).name}:{example.start_line}" + + +def test_guides_have_examples(): + """Guard against the extractor silently matching nothing.""" + assert len(RUNNABLE) >= 20, f"only found {len(RUNNABLE)} runnable examples" + + +def test_skip_count_is_pinned(): + """A newly unrunnable example should be a deliberate choice.""" + listing = "\n".join(f" {_example_id(ex)}" for ex in SKIPPED) + assert len(SKIPPED) == EXPECTED_SKIPS, ( + f"expected {EXPECTED_SKIPS} skipped examples, found {len(SKIPPED)}:\n{listing}" + ) + + +@pytest.fixture(autouse=True) +def restore_global_settings(): + """Undo any global setting an example changes. + + Some examples exist precisely to show a global toggle — the upgrade guide + demonstrates turning the camelCase bridge off with + ``fastmcp.settings.mcp_camelcase_compat = False``. Executing that here + would otherwise leave the bridge off for every test that runs afterwards in + the same process, which silently breaks unrelated suites. + """ + before = fastmcp.settings.model_dump() + yield + for field, value in before.items(): + if getattr(fastmcp.settings, field, value) != value: + setattr(fastmcp.settings, field, value) + + +@pytest.mark.parametrize("example", RUNNABLE, ids=[_example_id(e) for e in RUNNABLE]) +def test_example_executes(example: CodeExample): + """Every non-skipped example runs top to bottom without raising. + + Examples are executed under a module name other than ``__main__`` so an + ``if __name__ == "__main__": mcp.run()`` footer defines the server without + starting it. + """ + namespace: dict[str, object] = {"__name__": "fastmcp_docs_example"} + with warnings.catch_warnings(): + # Guides intentionally demonstrate deprecated surfaces (the camelCase + # bridge, SDK logging) whose warnings are the point being made. + warnings.simplefilter("ignore") + exec(compile(example.source, str(example.path), "exec"), namespace) diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index ad72b6168..bd5af842c 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -5,6 +5,7 @@ from pydantic import AnyUrl, BaseModel from fastmcp import Client, FastMCP from fastmcp.resources import Resource, ResourceContent, ResourceResult from fastmcp.resources.function_resource import FunctionResource +from tests.conftest import user_meta class TestResourceValidation: @@ -323,7 +324,7 @@ class TestResourceMetaPropagation: async with Client(mcp) as client: result = await client.read_resource_mcp("test://with-meta") - assert result.meta == {"version": "2.0", "source": "test"} + assert user_meta(result.meta) == {"version": "2.0", "source": "test"} async def test_resource_content_meta_received_by_client(self): """Meta set on ResourceContent is received by MCP client.""" @@ -355,7 +356,7 @@ class TestResourceMetaPropagation: async with Client(mcp) as client: result = await client.read_resource_mcp("test://both-meta") - assert result.meta == {"result_key": "result_val"} + assert user_meta(result.meta) == {"result_key": "result_val"} assert result.contents[0].meta == {"item_key": "item_val"} async def test_json_native_return_preserves_component_meta(self): diff --git a/tests/server/auth/oauth_proxy/test_authorization.py b/tests/server/auth/oauth_proxy/test_authorization.py index f3272094e..7b67ae934 100644 --- a/tests/server/auth/oauth_proxy/test_authorization.py +++ b/tests/server/auth/oauth_proxy/test_authorization.py @@ -55,6 +55,67 @@ class TestOAuthProxyAuthorization: assert transaction.client_state == "client-state-123" assert transaction.scopes == ["read", "write"] + async def test_authorize_records_configured_scopes_when_client_omits_scope( + self, oauth_proxy + ): + """A client that omits `scope` has the configured default recorded. + + The transaction is the single source of truth for what is being + authorized, so it must hold the effective scopes rather than an empty + list that each downstream consumer patches up on its own. + """ + client = OAuthClientInformationFull.model_validate( + { + "client_id": "test-client", + "client_secret": "test-secret", + "redirect_uris": ["http://localhost:54321/callback"], + "jwt_signing_key": "test-secret", + } + ) + await oauth_proxy.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:54321/callback"), + redirect_uri_provided_explicitly=True, + state="client-state-123", + code_challenge="challenge-abc", + scopes=None, + ) + + redirect_url = await oauth_proxy.authorize(client, params) + txn_id = parse_qs(urlparse(redirect_url).query)["txn_id"][0] + + transaction = await oauth_proxy._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.scopes == ["read", "write"] + + async def test_authorize_does_not_widen_explicit_client_scopes(self, oauth_proxy): + """An explicit narrow scope request is never widened to the configured set.""" + client = OAuthClientInformationFull.model_validate( + { + "client_id": "test-client", + "client_secret": "test-secret", + "redirect_uris": ["http://localhost:54321/callback"], + "jwt_signing_key": "test-secret", + } + ) + await oauth_proxy.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:54321/callback"), + redirect_uri_provided_explicitly=True, + state="client-state-123", + code_challenge="challenge-abc", + scopes=["read"], + ) + + redirect_url = await oauth_proxy.authorize(client, params) + txn_id = parse_qs(urlparse(redirect_url).query)["txn_id"][0] + + transaction = await oauth_proxy._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.scopes == ["read"] + class TestOAuthProxyPKCE: """Tests for OAuth proxy PKCE forwarding.""" diff --git a/tests/server/auth/oauth_proxy/test_client_registration.py b/tests/server/auth/oauth_proxy/test_client_registration.py index e4f2837d1..f90211349 100644 --- a/tests/server/auth/oauth_proxy/test_client_registration.py +++ b/tests/server/auth/oauth_proxy/test_client_registration.py @@ -227,6 +227,214 @@ class TestOAuthProxyClientRegistration: assert client_info.get("client_secret") is None +class TestApplicationTypeRegistration: + """SEP-837: DCR registration honors the client's application_type.""" + + async def test_default_application_type_is_native(self, oauth_proxy): + """Omitting application_type defaults to native (the SDK default), so a + loopback redirect URI registers successfully.""" + client_info = OAuthClientInformationFull( + client_id="default-client", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + await oauth_proxy.register_client(client_info) + + stored = await oauth_proxy.get_client("default-client") + assert stored is not None + assert stored.application_type == "native" + + async def test_native_loopback_range_registers_then_authorizes_new_port( + self, oauth_proxy + ): + """A native client on 127.0.0.2 keeps loopback port flexibility. + + Registration accepts the whole 127.0.0.0/8 range, and the stored client + must then authorize a different ephemeral port on that same address. + """ + client_info = OAuthClientInformationFull( + client_id="loopback-range-client", + redirect_uris=[AnyUrl("http://127.0.0.2:3000/callback")], + application_type="native", + ) + + await oauth_proxy.register_client(client_info) + + stored = await oauth_proxy.get_client("loopback-range-client") + assert stored is not None + + uri = stored.validate_redirect_uri(AnyUrl("http://127.0.0.2:54321/callback")) + assert str(uri) == "http://127.0.0.2:54321/callback" + + # A different host is still rejected — flexibility is loopback-only. + with pytest.raises(InvalidRedirectUriError): + stored.validate_redirect_uri( + AnyUrl("http://evil.example.com:54321/callback") + ) + + async def test_native_client_accepts_loopback(self, oauth_proxy): + client_info = OAuthClientInformationFull( + client_id="native-client", + redirect_uris=[AnyUrl("http://127.0.0.1:55555/callback")], + application_type="native", + ) + + await oauth_proxy.register_client(client_info) + + stored = await oauth_proxy.get_client("native-client") + assert stored is not None + assert stored.application_type == "native" + + async def test_web_client_accepts_https(self, oauth_proxy): + client_info = OAuthClientInformationFull( + client_id="web-client", + redirect_uris=[AnyUrl("https://client.example.com/callback")], + application_type="web", + ) + + await oauth_proxy.register_client(client_info) + + stored = await oauth_proxy.get_client("web-client") + assert stored is not None + assert stored.application_type == "web" + + async def test_web_client_rejects_loopback(self, oauth_proxy): + client_info = OAuthClientInformationFull( + client_id="web-loopback-client", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + application_type="web", + ) + + with pytest.raises(RegistrationError, match="application_type 'web'"): + await oauth_proxy.register_client(client_info) + + async def test_web_client_rejects_custom_scheme(self, oauth_proxy): + client_info = OAuthClientInformationFull( + client_id="web-custom-client", + redirect_uris=[AnyUrl("com.example.app:/oauth/callback")], + application_type="web", + ) + + with pytest.raises(RegistrationError, match="application_type 'web'"): + await oauth_proxy.register_client(client_info) + + async def test_web_client_without_redirect_uris_is_rejected(self, oauth_proxy): + """A web client with no redirect_uris could never authorize. + + Omitted redirect_uris fall back to the `http://localhost` placeholder, + which a web client can never use (loopback http fails its own rule), so + registering one would only create a client guaranteed to fail later. + """ + client_info = OAuthClientInformationFull( + client_id="web-no-uris", + redirect_uris=None, + application_type="web", + ) + + with pytest.raises(RegistrationError, match="required for application_type"): + await oauth_proxy.register_client(client_info) + + async def test_native_client_without_redirect_uris_still_allowed(self, oauth_proxy): + """Native clients may still defer redirect_uris to authorization time.""" + client_info = OAuthClientInformationFull( + client_id="native-no-uris", + redirect_uris=None, + application_type="native", + ) + + await oauth_proxy.register_client(client_info) + + stored = await oauth_proxy.get_client("native-no-uris") + assert stored is not None + + @pytest.mark.parametrize("application_type", ["web", "native"]) + async def test_unsafe_scheme_rejected_regardless_of_type( + self, oauth_proxy, application_type + ): + client_info = OAuthClientInformationFull( + client_id="unsafe-client", + redirect_uris=[AnyUrl("javascript:alert(document.cookie)//")], + application_type=application_type, + ) + + with pytest.raises(RegistrationError, match="invalid_redirect_uri"): + await oauth_proxy.register_client(client_info) + + +class TestApplicationTypeRegistrationOverHTTP: + """SEP-837: application_type is honored on the real POST /register route. + + The SDK's RegistrationHandler parses application_type but drops it before + calling register_client, so these tests exercise the actual ASGI route to + prove FastMCP recovers the value end to end (a direct register_client call + would not catch the SDK dropping the field).""" + + async def _register(self, oauth_proxy, payload: dict): + app = Starlette(routes=oauth_proxy.get_routes()) + transport = httpx2.ASGITransport(app=app) + async with httpx2.AsyncClient( + transport=transport, + base_url="https://myserver.com", + ) as client: + return await client.post("/register", json=payload) + + async def test_web_client_with_loopback_rejected_over_http(self, oauth_proxy): + response = await self._register( + oauth_proxy, + { + "redirect_uris": ["http://localhost:12345/callback"], + "application_type": "web", + }, + ) + + assert response.status_code == 400 + body = response.json() + assert body["error"] == "invalid_redirect_uri" + assert "application_type 'web'" in body["error_description"] + + async def test_web_client_with_https_accepted_over_http(self, oauth_proxy): + response = await self._register( + oauth_proxy, + { + "redirect_uris": ["https://client.example.com/callback"], + "application_type": "web", + }, + ) + + assert response.status_code == 201 + body = response.json() + assert body["application_type"] == "web" + + stored = await oauth_proxy.get_client(body["client_id"]) + assert stored is not None + assert stored.application_type == "web" + + async def test_native_client_with_loopback_accepted_over_http(self, oauth_proxy): + response = await self._register( + oauth_proxy, + { + "redirect_uris": ["http://localhost:12345/callback"], + "application_type": "native", + }, + ) + + assert response.status_code == 201 + body = response.json() + assert body["application_type"] == "native" + + async def test_default_application_type_is_native_over_http(self, oauth_proxy): + """Omitting application_type over HTTP defaults to native, so a loopback + redirect is accepted (preserving pre-SEP-837 behavior).""" + response = await self._register( + oauth_proxy, + {"redirect_uris": ["http://localhost:12345/callback"]}, + ) + + assert response.status_code == 201 + body = response.json() + assert body["application_type"] == "native" + + class TestUpstreamClientIdFallback: """Tests for clients that skip DCR and use the upstream client_id directly.""" diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py index 4e9289144..f5f15f5ab 100644 --- a/tests/server/auth/oauth_proxy/test_oauth_proxy.py +++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py @@ -10,6 +10,7 @@ from key_value.aio.stores.memory import MemoryStore from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl from starlette.applications import Starlette +from starlette.testclient import TestClient from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.oauth_proxy.models import OAuthTransaction @@ -618,3 +619,38 @@ class TestIdpCallbackSuccessForwarding: # byte-for-byte. assert "flag" in query assert "sig=%FF%FE" in query + + +class TestCIMDTokenEndpointAudience: + """The `aud` expected on a CIMD assertion matches the advertised token endpoint.""" + + @pytest.mark.parametrize( + "base_url", + ["https://api.example.com", "https://api.example.com/api"], + ) + def test_token_endpoint_url_matches_advertised_metadata( + self, jwt_verifier, base_url: str + ): + """A bare-authority base_url must not expect `aud` of `https://host//token`. + + CIMD is enabled by default, and a spec-following client binds its + private_key_jwt assertion to the `token_endpoint` the metadata + advertises. If the proxy expects a different string, every such client + is rejected with invalid_client. + """ + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="client-123", + upstream_client_secret="secret-456", + token_verifier=jwt_verifier, + base_url=base_url, + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + ) + + app = Starlette(routes=proxy.get_routes(mcp_path="/mcp")) + with TestClient(app) as client: + metadata = client.get("/.well-known/oauth-authorization-server").json() + + assert proxy.token_endpoint_url == metadata["token_endpoint"] diff --git a/tests/server/auth/test_authorization.py b/tests/server/auth/test_authorization.py index 844c3bdb5..b3f268806 100644 --- a/tests/server/auth/test_authorization.py +++ b/tests/server/auth/test_authorization.py @@ -8,10 +8,11 @@ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.exceptions import AuthorizationError +from fastmcp.exceptions import AuthorizationError, InsufficientScopeError from fastmcp.server.auth import ( AccessToken, AuthContext, + require_roles, require_scopes, restrict_tag, run_auth_checks, @@ -19,6 +20,7 @@ from fastmcp.server.auth import ( from fastmcp.server.middleware import AuthMiddleware from fastmcp.server.transforms import ToolTransform from fastmcp.tools.tool_transform import ToolTransformConfig, TransformedTool +from fastmcp.utilities.authorization import scope_requirements from fastmcp.utilities.versions import VersionSpec # ============================================================================= @@ -26,14 +28,17 @@ from fastmcp.utilities.versions import VersionSpec # ============================================================================= -def make_token(scopes: list[str] | None = None) -> AccessToken: +def make_token( + scopes: list[str] | None = None, + claims: dict | None = None, +) -> AccessToken: """Create a test access token.""" return AccessToken( token="test-token", client_id="test-client", scopes=scopes or [], expires_at=None, - claims={}, + claims=claims or {}, ) @@ -86,6 +91,155 @@ class TestRequireScopes: assert check(ctx) is False +# ============================================================================= +# Tests for require_roles +# ============================================================================= + + +KEYCLOAK = {"realm_access": {"roles": ["admin", "viewer"]}} + + +def keycloak_roles(claims: dict) -> list[str]: + return claims["realm_access"]["roles"] + + +class TestRequireRoles: + @pytest.mark.parametrize( + "claims, extract", + [ + (KEYCLOAK, keycloak_roles), + ({"roles": ["admin"]}, lambda c: c["roles"]), + ({"cognito:groups": ["admin"]}, lambda c: c["cognito:groups"]), + ({"permissions": ["admin"]}, lambda c: c["permissions"]), + ( + {"https://app.example.com/roles": ["admin"]}, + lambda c: c["https://app.example.com/roles"], + ), + ], + ) + def test_reads_roles_from_provider_specific_claim(self, claims, extract): + ctx = AuthContext(token=make_token(claims=claims), component=make_tool()) + assert require_roles("admin", extract=extract)(ctx) is True + + def test_requires_all_roles(self): + ctx = AuthContext(token=make_token(claims=KEYCLOAK), component=make_tool()) + check = require_roles("admin", "viewer", extract=keycloak_roles) + assert check(ctx) is True + + def test_denies_when_one_role_missing(self): + ctx = AuthContext(token=make_token(claims=KEYCLOAK), component=make_tool()) + check = require_roles("admin", "auditor", extract=keycloak_roles) + assert check(ctx) is False + + def test_denies_without_token(self): + ctx = AuthContext(token=None, component=make_tool()) + assert require_roles("admin", extract=keycloak_roles)(ctx) is False + + @pytest.mark.parametrize( + "claims", + [{}, {"realm_access": {}}, {"realm_access": None}, {"realm_access": []}], + ) + def test_denies_when_claim_absent_or_malformed(self, claims): + """A token without the claim is an ordinary denial, not a broken check.""" + ctx = AuthContext(token=make_token(claims=claims), component=make_tool()) + assert require_roles("admin", extract=keycloak_roles)(ctx) is False + + def test_rejects_empty_role_list(self): + """A check with no roles would admit any authenticated caller.""" + with pytest.raises(ValueError, match="at least one role"): + require_roles(extract=keycloak_roles) + + def test_is_opaque_to_scope_shortfall(self): + """Roles cannot be requested via OAuth, so they yield no step-up.""" + ctx = AuthContext(token=make_token(claims=KEYCLOAK), component=make_tool()) + check = require_roles("auditor", extract=keycloak_roles) + assert scope_requirements(check, ctx) is None + + def test_suppresses_shortfall_disclosure_of_sibling_scope_checks(self): + """One opaque check withholds the whole list's scope requirements.""" + token = make_token(scopes=["read"], claims=KEYCLOAK) + ctx = AuthContext(token=token, component=make_tool()) + checks = [ + require_scopes("write"), + require_roles("admin", extract=keycloak_roles), + ] + assert scope_requirements(checks, ctx) is None + assert scope_requirements([require_scopes("write")], ctx) == ["write"] + + @pytest.mark.parametrize( + "required, expected", + [("admin", True), ("a", False), ("dmin", False)], + ) + def test_scalar_role_claim_is_one_role(self, required: str, expected: bool): + """A provider storing one role as a string must not be iterated. + + `str` satisfies `Iterable[str]`, so a bare "admin" would otherwise + become the character set {a, d, m, i, n} — denying the "admin" it + plainly grants and granting any single character it contains. + """ + ctx = AuthContext( + token=make_token(claims={"role": "admin"}), component=make_tool() + ) + check = require_roles(required, extract=lambda c: c["role"]) + assert check(ctx) is expected + + async def test_role_denial_suppresses_scope_challenge(self): + """A caller blocked by their role is not told to obtain a scope.""" + mcp = FastMCP( + middleware=[ + AuthMiddleware( + auth=[ + require_scopes("api"), + require_roles("admin", extract=keycloak_roles), + ] + ) + ] + ) + + @mcp.tool + def t() -> str: + return "ok" + + token = make_token(scopes=["read"], claims={"realm_access": {"roles": ["v"]}}) + tok = set_token(token) + try: + with pytest.raises(AuthorizationError) as exc_info: + await mcp.call_tool("t", {}) + finally: + auth_context_var.reset(tok) + + assert not isinstance(exc_info.value, InsufficientScopeError) + + async def test_passing_role_still_allows_scope_challenge(self): + """Mixing the two checks does not disable step-up on its own.""" + mcp = FastMCP( + middleware=[ + AuthMiddleware( + auth=[ + require_scopes("api"), + require_roles("admin", extract=keycloak_roles), + ] + ) + ] + ) + + @mcp.tool + def t() -> str: + return "ok" + + token = make_token( + scopes=["read"], claims={"realm_access": {"roles": ["admin"]}} + ) + tok = set_token(token) + try: + with pytest.raises(InsufficientScopeError) as exc_info: + await mcp.call_tool("t", {}) + finally: + auth_context_var.reset(tok) + + assert exc_info.value.required_scopes == ["api"] + + # ============================================================================= # Tests for restrict_tag # ============================================================================= @@ -1008,3 +1162,324 @@ class TestComponentAuthDenialMessage: assert "not found or not authorized" in message finally: auth_context_var.reset(tok) + + +# ============================================================================= +# Tests for component-level scope step-up signalling (SEP-2350) +# ============================================================================= + + +class TestInsufficientScopeSignal: + """A scope shortfall on a globally-authorized component is surfaced as an + ``InsufficientScopeError`` naming the unmet scopes, the component-level + analog of the transport-level ``insufficient_scope`` challenge. The named + scopes are only those the token lacks, so an existing grant accumulates + rather than being replaced when the caller re-authorizes. + """ + + async def test_call_tool_missing_scope_names_required_scope(self): + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=["read"])) + try: + with pytest.raises(InsufficientScopeError) as exc_info: + await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert exc_info.value.required_scopes == ["api"] + assert "api" in str(exc_info.value) + + async def test_insufficient_scope_error_is_authorization_error(self): + # Existing `except AuthorizationError` sites must still catch it. + assert issubclass(InsufficientScopeError, AuthorizationError) + + async def test_call_tool_sufficient_scope_passes(self): + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=["api", "read"])) + try: + result = await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert result.content[0].text == "ok" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_shortfall_names_only_unmet_scopes(self): + # The token already carries "read"; only the missing "api" is named, so a + # re-authorization accumulates scopes rather than dropping "read". + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("read", "api"))]) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=["read"])) + try: + with pytest.raises(InsufficientScopeError) as exc_info: + await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert exc_info.value.required_scopes == ["api"] + + async def test_missing_token_is_not_insufficient_scope(self): + # No token is an authentication failure (RFC 6750 §3.1), not a scope + # shortfall: it must not be turned into an insufficient_scope signal. + mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) + + @mcp.tool + def api_tool() -> str: + return "ok" + + with pytest.raises(AuthorizationError) as exc_info: + await mcp.call_tool("api_tool", {}) + + assert not isinstance(exc_info.value, InsufficientScopeError) + + async def test_non_scope_denial_stays_opaque_and_names_no_scope(self): + # A non-scope check (e.g. a custom tenant policy) fails first and + # short-circuits before the scope check runs. The denial must stay a + # plain AuthorizationError and must NOT disclose or request the "admin" + # scope for a component the caller could not otherwise reach. + def deny_tenant(ctx: AuthContext) -> bool: + return False + + mcp = FastMCP( + middleware=[ + AuthMiddleware(auth=[deny_tenant, require_scopes("admin")]), + ] + ) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=["read"])) + try: + with pytest.raises(AuthorizationError) as exc_info: + await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert not isinstance(exc_info.value, InsufficientScopeError) + assert "admin" not in str(exc_info.value) + + async def test_shortfall_unions_every_unmet_scope_check(self): + # Naming only the first failing check would strand the caller in a + # step-up loop: they obtain "read", retry, and are denied for "write". + mcp = FastMCP( + middleware=[ + AuthMiddleware(auth=[require_scopes("read"), require_scopes("write")]), + ] + ) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=[])) + try: + with pytest.raises(InsufficientScopeError) as exc_info: + await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert exc_info.value.required_scopes == ["read", "write"] + + async def test_shortfall_union_drops_already_granted_scope(self): + mcp = FastMCP( + middleware=[ + AuthMiddleware(auth=[require_scopes("read"), require_scopes("write")]), + ] + ) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=["read"])) + try: + with pytest.raises(InsufficientScopeError) as exc_info: + await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert exc_info.value.required_scopes == ["write"] + + async def test_shortfall_unions_across_middleware_chain(self): + # Scope requirements split across two AuthMiddleware instances. The + # outer one raises before the inner ever runs, so its shortfall has to + # account for the inner requirement or the caller loops. + mcp = FastMCP( + middleware=[ + AuthMiddleware(auth=require_scopes("admin")), + AuthMiddleware(auth=require_scopes("write")), + ] + ) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=[])) + try: + with pytest.raises(InsufficientScopeError) as exc_info: + await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert exc_info.value.required_scopes == ["admin", "write"] + + async def test_chain_shortfall_drops_already_granted_scope(self): + mcp = FastMCP( + middleware=[ + AuthMiddleware(auth=require_scopes("admin")), + AuthMiddleware(auth=require_scopes("write")), + ] + ) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=["admin"])) + try: + with pytest.raises(InsufficientScopeError) as exc_info: + await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert exc_info.value.required_scopes == ["write"] + + async def test_opaque_denial_in_chain_stays_opaque(self): + # An opaque check denies in the outer middleware; the inner middleware's + # scope requirement must not be disclosed. + def deny_tenant(ctx: AuthContext) -> bool: + return False + + mcp = FastMCP( + middleware=[ + AuthMiddleware(auth=deny_tenant), + AuthMiddleware(auth=require_scopes("write")), + ] + ) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=[])) + try: + with pytest.raises(AuthorizationError) as exc_info: + await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert not isinstance(exc_info.value, InsufficientScopeError) + assert "write" not in str(exc_info.value) + + async def test_chain_shortfall_withholds_scopes_of_opaque_sibling(self): + # The outer middleware has a real scope shortfall, but the inner one + # pairs its scope requirement with an opaque check whose verdict is + # unknown. That layer's scope must not be disclosed. + def opaque_policy(ctx: AuthContext) -> bool: + return True + + mcp = FastMCP( + middleware=[ + AuthMiddleware(auth=require_scopes("admin")), + AuthMiddleware(auth=[opaque_policy, require_scopes("write")]), + ] + ) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=[])) + try: + with pytest.raises(InsufficientScopeError) as exc_info: + await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert exc_info.value.required_scopes == ["admin"] + + async def test_chain_shortfall_stops_at_unevaluated_opaque_layer(self): + # The opaque middleware sits between two scope layers. The outer one + # raises before it ever runs, so whether it would admit the caller is + # unknown — and the scope behind it must not be disclosed. It returns + # True here to show the walk stops regardless of what the verdict + # would have been. + def tenant_check(ctx: AuthContext) -> bool: + return True + + mcp = FastMCP( + middleware=[ + AuthMiddleware(auth=require_scopes("admin")), + AuthMiddleware(auth=tenant_check), + AuthMiddleware(auth=require_scopes("write")), + ] + ) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=[])) + try: + with pytest.raises(InsufficientScopeError) as exc_info: + await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert exc_info.value.required_scopes == ["admin"] + + async def test_chain_shortfall_spans_consecutive_scope_only_layers(self): + # The same chain without the opaque layer aggregates all of it, proving + # the reachability bound does not over-correct. + mcp = FastMCP( + middleware=[ + AuthMiddleware(auth=require_scopes("admin")), + AuthMiddleware(auth=require_scopes("write")), + AuthMiddleware(auth=require_scopes("delete")), + ] + ) + + @mcp.tool + def api_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=[])) + try: + with pytest.raises(InsufficientScopeError) as exc_info: + await mcp.call_tool("api_tool", {}) + finally: + auth_context_var.reset(tok) + + assert exc_info.value.required_scopes == ["admin", "delete", "write"] + + async def test_restrict_tag_shortfall_names_scope(self): + mcp = make_restricted_tag_server() + + @mcp.tool(tags={"admin"}) + def admin_tool() -> str: + return "ok" + + tok = set_token(make_token(scopes=["read"])) + try: + with pytest.raises(InsufficientScopeError) as exc_info: + await mcp.call_tool("admin_tool", {}) + finally: + auth_context_var.reset(tok) + + assert exc_info.value.required_scopes == ["admin"] diff --git a/tests/server/auth/test_issuer_url_identity.py b/tests/server/auth/test_issuer_url_identity.py new file mode 100644 index 000000000..8a27374f8 --- /dev/null +++ b/tests/server/auth/test_issuer_url_identity.py @@ -0,0 +1,297 @@ +"""Tests that `issuer_url` is authoritative for authorization server identity. + +Regression tests for #4610. `issuer_url` lets the OAuth issuer identity differ +from `base_url`, which is where the OAuth endpoints are actually mounted. The +issuer identity — the `issuer` field of the authorization server metadata, the +`iss` claim of minted tokens, and the RFC 9207 `iss` authorization response +parameter — must come from `issuer_url`, while every endpoint URL must keep +coming from `base_url`. + +RFC 8414 §3.3 is the reason this matters: the protected resource metadata points +clients at `issuer_url`, the client performs discovery there, and the `issuer` +in the returned metadata must match the identifier used for discovery. +""" + +import re +import time +from urllib.parse import parse_qs, urlparse + +import httpx2 +import pytest +from key_value.aio.stores.memory import MemoryStore +from mcp.server.auth.provider import AuthorizationParams +from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions +from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.testclient import TestClient + +from fastmcp import FastMCP +from fastmcp.server.auth.auth import AccessToken, TokenVerifier +from fastmcp.server.auth.identity_assertion import IdentityAssertion +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider + +# The server is mounted under /api, so its endpoints live at BASE_URL while its +# issuer identity is the root of the same host. +BASE_URL = "https://api.example.com/api" +ISSUER_URL = "https://api.example.com" + +# Pydantic renders a bare-authority AnyHttpUrl with a trailing slash. +ISSUER = "https://api.example.com/" +BASE_URL_ISSUER = "https://api.example.com/api" + + +class _Verifier(TokenVerifier): + """Minimal token verifier.""" + + def __init__(self): + self.required_scopes = ["read"] + + async def verify_token(self, token: str) -> AccessToken: + return AccessToken( + token=token, + client_id="client-id", + scopes=self.required_scopes, + expires_at=int(time.time() + 3600), + ) + + +def build_proxy(issuer_url: str | None) -> OAuthProxy: + """Build an OAuth proxy mounted at BASE_URL, optionally with a distinct issuer.""" + return OAuthProxy( + upstream_authorization_endpoint="https://upstream.example.com/authorize", + upstream_token_endpoint="https://upstream.example.com/token", + upstream_revocation_endpoint="https://upstream.example.com/revoke", + upstream_client_id="client-id", + upstream_client_secret="client-secret", + token_verifier=_Verifier(), + base_url=BASE_URL, + issuer_url=issuer_url, + client_storage=MemoryStore(), + jwt_signing_key="test-secret", + ) + + +def build_provider(issuer_url: str | None) -> InMemoryOAuthProvider: + """Build a plain OAuth provider mounted at BASE_URL, optionally with a distinct issuer.""" + return InMemoryOAuthProvider( + base_url=BASE_URL, + issuer_url=issuer_url, + client_registration_options=ClientRegistrationOptions(enabled=True), + revocation_options=RevocationOptions(enabled=True), + ) + + +def build_mounted_app(auth_provider) -> Starlette: + """Mount an authenticated FastMCP server under /api with well-known routes at root.""" + mcp = FastMCP("test-server", auth=auth_provider) + mcp_app = mcp.http_app(path="/mcp") + return Starlette( + routes=[ + *auth_provider.get_well_known_routes(mcp_path="/mcp"), + Mount("/api", app=mcp_app), + ], + lifespan=mcp_app.lifespan, + ) + + +async def fetch_json(auth_provider, path: str) -> dict: + """Fetch a well-known document from a mounted authenticated server.""" + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=build_mounted_app(auth_provider)), + base_url=ISSUER_URL, + ) as client: + response = await client.get(path) + assert response.status_code == 200 + return response.json() + + +class TestOAuthProxyIssuerIdentity: + """`OAuthProxy` (and therefore `OIDCProxy`) identity comes from `issuer_url`.""" + + async def test_protected_resource_metadata_points_at_issuer_url(self): + metadata = await fetch_json( + build_proxy(ISSUER_URL), "/.well-known/oauth-protected-resource/api/mcp" + ) + assert metadata["authorization_servers"] == [ISSUER] + + async def test_authorization_server_metadata_issuer_is_issuer_url(self): + metadata = await fetch_json( + build_proxy(ISSUER_URL), "/.well-known/oauth-authorization-server" + ) + assert metadata["issuer"] == ISSUER + + @pytest.mark.parametrize( + "field, expected", + [ + ("authorization_endpoint", f"{BASE_URL}/authorize"), + ("token_endpoint", f"{BASE_URL}/token"), + ("registration_endpoint", f"{BASE_URL}/register"), + ("revocation_endpoint", f"{BASE_URL}/revoke"), + ], + ) + async def test_endpoints_stay_on_base_url(self, field: str, expected: str): + metadata = await fetch_json( + build_proxy(ISSUER_URL), "/.well-known/oauth-authorization-server" + ) + assert metadata[field] == expected + + async def test_minted_token_iss_claim_is_issuer_url(self): + proxy = build_proxy(ISSUER_URL) + # get_routes() configures the MCP path, which creates the JWT issuer. + proxy.get_routes(mcp_path="/mcp") + + token = proxy.jwt_issuer.issue_access_token( + client_id="client-id", scopes=["read"], jti="test-jti" + ) + + assert proxy.jwt_issuer.verify_token(token)["iss"] == ISSUER + + async def test_authorization_response_iss_matches_metadata_issuer(self): + """RFC 9207: the `iss` on a client-facing response matches the metadata.""" + proxy = build_proxy(ISSUER_URL) + redirect = "http://localhost:5100/callback" + client = OAuthClientInformationFull( + client_id="rfc9207-client", + client_secret="s", + redirect_uris=[AnyUrl(redirect)], + ) + await proxy.register_client(client) + consent_url = await proxy.authorize( + client, + AuthorizationParams( + redirect_uri=AnyUrl(redirect), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="challenge", + scopes=["read"], + ), + ) + txn_id = parse_qs(urlparse(consent_url).query)["txn_id"][0] + + app = Starlette(routes=proxy.get_routes()) + with TestClient(app) as test_client: + metadata = test_client.get("/.well-known/oauth-authorization-server").json() + + consent = test_client.get(f"/consent?txn_id={txn_id}") + csrf_match = re.search( + r"name=\"csrf_token\"\s+value=\"([^\"]+)\"", consent.text + ) + assert csrf_match + for name, value in consent.cookies.items(): + test_client.cookies.set(name, value) + + denial = test_client.post( + "/consent", + data={ + "action": "deny", + "txn_id": txn_id, + "csrf_token": csrf_match.group(1), + }, + follow_redirects=False, + ) + + assert denial.status_code in (302, 303) + params = parse_qs(urlparse(denial.headers["location"]).query) + assert params["iss"] == [ISSUER] + assert params["iss"] == [metadata["issuer"]] + + @pytest.mark.parametrize( + "issuer_url, expected", + [(ISSUER_URL, ISSUER), (None, BASE_URL_ISSUER)], + ) + def test_identity_assertion_audience_is_issuer_identifier( + self, issuer_url: str | None, expected: str + ): + """SEP-990: an ID-JAG is bound to the server's advertised issuer. + + RFC 7523 §3 requires the `aud` to identify the authorization server, + and an authorization server is identified by its issuer — the value + published as `issuer` in the authorization server metadata. + """ + proxy = OAuthProxy( + upstream_authorization_endpoint="https://upstream.example.com/authorize", + upstream_token_endpoint="https://upstream.example.com/token", + upstream_client_id="client-id", + upstream_client_secret="client-secret", + token_verifier=_Verifier(), + base_url=BASE_URL, + issuer_url=issuer_url, + client_storage=MemoryStore(), + jwt_signing_key="test-secret", + identity_assertion=IdentityAssertion( + trusted_issuers=["https://login.example.com"] + ), + ) + + validator = proxy._identity_assertion_validator + assert validator is not None + assert validator.audience == [expected.rstrip("/"), f"{expected.rstrip('/')}/"] + + +class TestOAuthProxyIssuerDefaults: + """With `issuer_url` unset, identity falls back to `base_url` as before.""" + + async def test_authorization_server_metadata_issuer_is_base_url(self): + # base_url has a path, so RFC 8414 path-aware discovery applies. + metadata = await fetch_json( + build_proxy(None), "/.well-known/oauth-authorization-server/api" + ) + assert metadata["issuer"] == BASE_URL_ISSUER + + async def test_protected_resource_metadata_points_at_base_url(self): + metadata = await fetch_json( + build_proxy(None), "/.well-known/oauth-protected-resource/api/mcp" + ) + assert metadata["authorization_servers"] == [BASE_URL_ISSUER] + + async def test_minted_token_iss_claim_is_base_url(self): + proxy = build_proxy(None) + proxy.get_routes(mcp_path="/mcp") + + token = proxy.jwt_issuer.issue_access_token( + client_id="client-id", scopes=["read"], jti="test-jti" + ) + + assert proxy.jwt_issuer.verify_token(token)["iss"] == BASE_URL_ISSUER + + +class TestOAuthProviderIssuerIdentity: + """The plain `OAuthProvider` path behaves the same way.""" + + async def test_authorization_server_metadata_issuer_is_issuer_url(self): + metadata = await fetch_json( + build_provider(ISSUER_URL), "/.well-known/oauth-authorization-server" + ) + assert metadata["issuer"] == ISSUER + + async def test_protected_resource_metadata_points_at_issuer_url(self): + metadata = await fetch_json( + build_provider(ISSUER_URL), + "/.well-known/oauth-protected-resource/api/mcp", + ) + assert metadata["authorization_servers"] == [ISSUER] + + @pytest.mark.parametrize( + "field, expected", + [ + ("authorization_endpoint", f"{BASE_URL}/authorize"), + ("token_endpoint", f"{BASE_URL}/token"), + ("registration_endpoint", f"{BASE_URL}/register"), + ("revocation_endpoint", f"{BASE_URL}/revoke"), + ], + ) + async def test_endpoints_stay_on_base_url(self, field: str, expected: str): + metadata = await fetch_json( + build_provider(ISSUER_URL), "/.well-known/oauth-authorization-server" + ) + assert metadata[field] == expected + + async def test_issuer_defaults_to_base_url(self): + # base_url has a path, so RFC 8414 path-aware discovery applies. + metadata = await fetch_json( + build_provider(None), "/.well-known/oauth-authorization-server/api" + ) + assert metadata["issuer"] == BASE_URL_ISSUER diff --git a/tests/server/auth/test_oauth_mounting.py b/tests/server/auth/test_oauth_mounting.py index 50bf67b1a..db4e9a77f 100644 --- a/tests/server/auth/test_oauth_mounting.py +++ b/tests/server/auth/test_oauth_mounting.py @@ -209,7 +209,8 @@ class TestOAuthMounting: Scenario: FastMCP server mounted at /api prefix - issuer_url: https://api.example.com (root level) - base_url: https://api.example.com/api (includes mount prefix) - - Expected: metadata declares endpoints at base_url + - Expected: metadata declares endpoints at base_url and issuer at + issuer_url """ # Create OAuth proxy with different base_url and issuer_url token_verifier = StaticTokenVerifier(tokens=test_tokens) @@ -261,12 +262,10 @@ class TestOAuthMounting: == "https://api.example.com/api/register" ) - # The issuer field should use base_url (where the server is actually running) - # Note: MCP SDK may or may not add a trailing slash - assert metadata["issuer"] in [ - "https://api.example.com/api", - "https://api.example.com/api/", - ] + # The issuer field reports issuer_url: it is the identifier the + # client used for RFC 8414 discovery, and §3.3 requires the two to + # match. Only the endpoint URLs follow base_url. + assert metadata["issuer"] == "https://api.example.com/" async def test_oauth_authorization_server_metadata_path_aware_discovery( self, test_tokens diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index 15f2f8d41..72cf68c2a 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -11,6 +11,10 @@ from fastmcp.server.auth.auth import TokenVerifier from fastmcp.server.auth.cimd import CIMDDocument from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient +from fastmcp.server.auth.redirect_validation import ( + is_loopback_host, + is_redirect_uri_allowed_for_application_type, +) # Standard public IP used for DNS mocking in tests TEST_PUBLIC_IP = "93.184.216.34" @@ -492,3 +496,316 @@ class TestOAuthProxyCIMDClient: # NOT in CIMD but matches proxy pattern → rejected with pytest.raises(InvalidRedirectUriError): client.validate_redirect_uri(AnyUrl("http://localhost:9999/other")) + + +class TestRegisteredLoopbackPortFlexibility: + """The registered-URI port-flexible match uses the shared loopback classifier. + + `models.py` previously carried its own `_is_loopback_host` that only knew + `127.0.0.1`, so a client registered on another address in `127.0.0.0/8` + silently lost port flexibility and was rejected as unregistered. + """ + + @pytest.mark.parametrize( + "host", + ["127.0.0.1", "127.0.0.2", "127.5.5.5", "localhost", "app.localhost"], + ) + def test_loopback_range_keeps_port_flexibility(self, host: str): + client = ProxyDCRClient( + client_id="native", + client_secret="secret", + redirect_uris=[AnyUrl(f"http://{host}:3000/callback")], + ) + + uri = client.validate_redirect_uri(AnyUrl(f"http://{host}:54321/callback")) + assert str(uri) == f"http://{host}:54321/callback" + + def test_non_loopback_host_still_requires_exact_match(self): + """Port flexibility is loopback-only; other hosts must match exactly.""" + client = ProxyDCRClient( + client_id="external", + client_secret="secret", + redirect_uris=[AnyUrl("https://client.example.com:3000/callback")], + ) + + uri = client.validate_redirect_uri( + AnyUrl("https://client.example.com:3000/callback") + ) + assert str(uri) == "https://client.example.com:3000/callback" + + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri( + AnyUrl("https://client.example.com:54321/callback") + ) + + +class TestStoredApplicationTypeAtAuthorization: + """SEP-837: a stored client's application_type is enforced at authorization.""" + + def test_web_client_rejects_loopback_at_authorization(self): + """A registered web client cannot later authorize a loopback redirect.""" + client = ProxyDCRClient( + client_id="web", + client_secret="secret", + redirect_uris=[AnyUrl("https://client.example.com/callback")], + application_type="web", + ) + + uri = client.validate_redirect_uri( + AnyUrl("https://client.example.com/callback") + ) + assert str(uri) == "https://client.example.com/callback" + + with pytest.raises(InvalidRedirectUriError, match="application_type 'web'"): + client.validate_redirect_uri(AnyUrl("http://localhost:8080/callback")) + + def test_web_client_rejects_loopback_even_when_pattern_allows(self): + """The application_type check applies on top of the global allowlist.""" + client = ProxyDCRClient( + client_id="web", + client_secret="secret", + redirect_uris=[AnyUrl("https://client.example.com/callback")], + application_type="web", + allowed_redirect_uri_patterns=["http://localhost:*", "https://*/*"], + ) + + with pytest.raises(InvalidRedirectUriError, match="application_type 'web'"): + client.validate_redirect_uri(AnyUrl("http://localhost:8080/callback")) + + def test_native_client_accepts_loopback_at_authorization(self): + client = ProxyDCRClient( + client_id="native", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:8080/callback")], + application_type="native", + ) + + uri = client.validate_redirect_uri(AnyUrl("http://localhost:55555/callback")) + assert str(uri) == "http://localhost:55555/callback" + + def test_web_client_rejects_localhost_namespace_at_authorization(self): + """The shared classifier means the namespace fix reaches this path too.""" + client = ProxyDCRClient( + client_id="web", + client_secret="secret", + redirect_uris=[AnyUrl("https://client.example.com/callback")], + application_type="web", + allowed_redirect_uri_patterns=["https://*/*"], + ) + + with pytest.raises(InvalidRedirectUriError, match="application_type 'web'"): + client.validate_redirect_uri(AnyUrl("https://app.localhost/callback")) + + +class TestApplicationTypeRedirectRules: + """SEP-837: application_type governs the web vs native redirect rules.""" + + @pytest.mark.parametrize( + "uri", + [ + "https://client.example.com/callback", + "https://app.example.com:8443/oauth/callback", + ], + ) + def test_web_accepts_https(self, uri: str): + assert is_redirect_uri_allowed_for_application_type(uri, "web") is True + + @pytest.mark.parametrize( + "uri", + [ + "http://127.0.0.1:8080/callback", + "http://localhost:12345/callback", + "http://[::1]:9000/callback", + "https://localhost/callback", + "com.example.app:/oauth/callback", + "myapp://callback", + "http://client.example.com/callback", + ], + ) + def test_web_rejects_loopback_and_custom_schemes(self, uri: str): + """Web clients must use https on a non-loopback host.""" + assert is_redirect_uri_allowed_for_application_type(uri, "web") is False + + @pytest.mark.parametrize( + "uri", + [ + "http://127.0.0.1:8080/callback", + "http://localhost:12345/callback", + "http://[::1]:9000/callback", + "com.example.app:/oauth/callback", + "cursor://anysphere.cursor-mcp/oauth/callback", + "myapp://callback", + "https://client.example.com/callback", + ], + ) + def test_native_accepts_loopback_and_custom_schemes(self, uri: str): + assert is_redirect_uri_allowed_for_application_type(uri, "native") is True + + @pytest.mark.parametrize( + "uri", + [ + "http://client.example.com/callback", + "http://example.com:8080/callback", + ], + ) + def test_native_rejects_non_loopback_cleartext_http(self, uri: str): + """Native may use cleartext http only against a loopback host.""" + assert is_redirect_uri_allowed_for_application_type(uri, "native") is False + + @pytest.mark.parametrize( + "uri", + [ + # Real MCP client callbacks — these must keep working. + "vscode://callback", + "vscode-insiders://callback", + "urn:ietf:wg:oauth:2.0:oob", + "cursor://anysphere.cursor-mcp/oauth/callback", + # Reverse-domain and plain app schemes. + "com.example.app://callback", + "com.example.app:/oauth/callback", + "myapp://callback", + ], + ) + def test_native_accepts_app_and_private_use_schemes(self, uri: str): + """Native clients keep every scheme outside the unsafe set. + + FastMCP deliberately does not try to classify a native client's scheme + as "private-use" versus "network transport": the IANA registry lists + `vscode` (an app-dispatch scheme) alongside `coap` and `smb`, so no + membership test separates the two without rejecting schemes that real + MCP clients depend on. + """ + assert is_redirect_uri_allowed_for_application_type(uri, "native") is True + + @pytest.mark.parametrize( + "host", + ["127.0.0.1", "127.0.0.2", "127.5.5.5", "127.255.255.254"], + ) + def test_web_rejects_entire_loopback_range(self, host: str): + """RFC 8252 §7.3 loopback is all of 127.0.0.0/8, not just 127.0.0.1. + + Checking only 127.0.0.1 would let a web client bypass the non-loopback + requirement with any other address in the range. + """ + uri = f"https://{host}/callback" + assert is_redirect_uri_allowed_for_application_type(uri, "web") is False + + @pytest.mark.parametrize( + "uri", + [ + "http://127.0.0.1:1234/cb", + "http://127.0.0.2:1234/cb", + "http://127.5.5.5:1234/cb", + "http://[::1]:1234/cb", + "http://localhost:1234/cb", + ], + ) + def test_native_accepts_entire_loopback_range(self, uri: str): + """The widened loopback range cuts both ways: native gains 127.0.0.0/8.""" + assert is_redirect_uri_allowed_for_application_type(uri, "native") is True + + +class TestLocalhostNamespaceIsLoopback: + """RFC 6761 §6.3 reserves the whole `localhost` namespace for the local machine.""" + + @pytest.mark.parametrize( + "host", + [ + "localhost", + "localhost.", # absolute (FQDN) form + "LOCALHOST", + "app.localhost", # reserved namespace + "api.app.localhost", + "App.LocalHost", + "evil.localhost", # .localhost is a reserved TLD — genuinely local + "127.0.0.1", + "127.0.0.1.", # absolute form of an IP literal + "127.0.0.2.", + "::1", + "[::1]", + ], + ) + def test_loopback_names_and_literals(self, host: str): + assert is_loopback_host(host) is True + + @pytest.mark.parametrize( + "host", + [ + # `localhost` as a *label* of a registrable domain is not local. The + # suffix test is anchored on a leading dot so these cannot spoof it. + "localhost.evil.com", + "localhost.evil.com.", + "notlocalhost", + "mylocalhost", + "localhostx", + "evil.com", + "", + ".", + ], + ) + def test_non_loopback_names_are_not_spoofable(self, host: str): + assert is_loopback_host(host) is False + + @pytest.mark.parametrize( + "uri", + [ + "https://app.localhost/cb", + "https://localhost./cb", + "https://api.app.localhost/cb", + "https://127.0.0.1./cb", + ], + ) + def test_web_rejects_localhost_namespace(self, uri: str): + """Web clients must not reach the local machine by name.""" + assert is_redirect_uri_allowed_for_application_type(uri, "web") is False + + @pytest.mark.parametrize( + "uri", + [ + "https://localhost.evil.com/cb", + "https://notlocalhost/cb", + ], + ) + def test_web_still_accepts_ordinary_public_https(self, uri: str): + """Names that merely contain 'localhost' remain ordinary public hosts.""" + assert is_redirect_uri_allowed_for_application_type(uri, "web") is True + + @pytest.mark.parametrize( + "uri", + [ + "http://app.localhost:3000/cb", + "http://localhost.:3000/cb", + "http://api.app.localhost:3000/cb", + "http://127.0.0.1.:3000/cb", + ], + ) + def test_native_accepts_localhost_namespace(self, uri: str): + """These are legitimate loopback dev callbacks and must not be rejected.""" + assert is_redirect_uri_allowed_for_application_type(uri, "native") is True + + @pytest.mark.parametrize( + "uri", + [ + "http://localhost.evil.com:3000/cb", + "http://notlocalhost:3000/cb", + ], + ) + def test_native_rejects_plain_http_to_non_loopback_lookalikes(self, uri: str): + assert is_redirect_uri_allowed_for_application_type(uri, "native") is False + + @pytest.mark.parametrize("application_type", ["web", "native"]) + @pytest.mark.parametrize( + "uri", + [ + "javascript:alert(document.cookie)//", + "data:text/html,", + "file:///etc/passwd", + "vbscript:msgbox(1)", + ], + ) + def test_unsafe_schemes_rejected_for_all_types( + self, uri: str, application_type: str + ): + assert ( + is_redirect_uri_allowed_for_application_type(uri, application_type) is False + ) diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py index 2636a1c83..755049120 100644 --- a/tests/server/auth/test_oidc_proxy.py +++ b/tests/server/auth/test_oidc_proxy.py @@ -982,3 +982,139 @@ class TestDiscoveryTimeout: ), ) assert timeout == 10 + + +class TestOIDCProxyValidScopes: + """Tests for the valid_scopes parameter on OIDCProxy.""" + + def test_valid_scopes_widens_advertised_set(self, valid_oidc_configuration_dict): + """valid_scopes broadens the advertised/registerable set beyond required_scopes.""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + required_scopes=["openid"], + valid_scopes=["openid", "email", "calendar"], + jwt_signing_key="test-secret", + ) + + assert proxy.client_registration_options is not None + assert proxy.client_registration_options.valid_scopes == [ + "openid", + "email", + "calendar", + ] + assert proxy.client_registration_options.default_scopes == [ + "openid", + "email", + "calendar", + ] + assert proxy._default_scope_str == "openid email calendar" + + def test_valid_scopes_defaults_to_required_scopes( + self, valid_oidc_configuration_dict + ): + """Omitting valid_scopes falls back to required_scopes (existing behavior).""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + required_scopes=["read", "write"], + jwt_signing_key="test-secret", + ) + + assert proxy.client_registration_options is not None + assert proxy.client_registration_options.valid_scopes == ["read", "write"] + + def test_valid_scopes_with_custom_token_verifier( + self, valid_oidc_configuration_dict + ): + """valid_scopes is allowed alongside a custom token_verifier (no error).""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + custom_verifier = IntrospectionTokenVerifier( + introspection_url="https://example.com/oauth/introspect", + client_id="introspection-client", + client_secret="introspection-secret", + required_scopes=["read"], + ) + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + token_verifier=custom_verifier, + valid_scopes=["read", "write", "admin"], + jwt_signing_key="test-secret", + ) + + assert proxy.client_registration_options is not None + assert proxy.client_registration_options.valid_scopes == [ + "read", + "write", + "admin", + ] + + def test_valid_scopes_preserved_with_verify_id_token( + self, valid_oidc_configuration_dict + ): + """verify_id_token restores required_scopes without clobbering valid_scopes.""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + required_scopes=["read"], + valid_scopes=["read", "write", "admin"], + verify_id_token=True, + jwt_signing_key="test-secret", + ) + + # Enforcement floor is restored to required_scopes... + assert proxy.required_scopes == ["read"] + # ...but the advertised/registerable set keeps the full valid_scopes. + assert proxy.client_registration_options is not None + assert proxy.client_registration_options.valid_scopes == [ + "read", + "write", + "admin", + ] + assert proxy.client_registration_options.default_scopes == [ + "read", + "write", + "admin", + ] + assert proxy._default_scope_str == "read write admin" diff --git a/tests/server/auth/test_static_token_verifier.py b/tests/server/auth/test_static_token_verifier.py index bfa0f3dbb..8a7f0507b 100644 --- a/tests/server/auth/test_static_token_verifier.py +++ b/tests/server/auth/test_static_token_verifier.py @@ -103,7 +103,7 @@ class TestStaticTokenVerifier: """Test that server raises error when both OAuth and TokenVerifier provided.""" from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider - oauth_provider = InMemoryOAuthProvider("http://test.com") + oauth_provider = InMemoryOAuthProvider(base_url="http://test.com") token_verifier = StaticTokenVerifier({"token": {"client_id": "test"}}) # This should work - OAuth provider diff --git a/tests/server/http/test_routable_headers.py b/tests/server/http/test_routable_headers.py new file mode 100644 index 000000000..99c02c50e --- /dev/null +++ b/tests/server/http/test_routable_headers.py @@ -0,0 +1,89 @@ +"""Routable transport headers (SEP-2243) survive a FastMCP HTTP round trip. + +The MCP Python SDK emits the routing headers on the client (`ClientSession`) and +validates them on the modern streamable-HTTP server transport. These tests are +FastMCP's regression guard: they prove FastMCP's HTTP layer neither strips nor +blocks the headers, so a gateway sitting in front of a FastMCP server can route +on them. The tool echoes back the raw request headers it received, letting the +test assert on exactly what reached the server. +""" + +from typing import Annotated + +from pydantic import Field + +from fastmcp.server.dependencies import get_http_request +from fastmcp.server.server import FastMCP +from fastmcp.utilities.tests import asgi_server + + +def _echo_server() -> FastMCP: + server = FastMCP() + + @server.tool + def echo_headers( + tenant: Annotated[ + str, Field(json_schema_extra={"x-mcp-header": "Tenant"}) + ] = "acme", + ) -> dict[str, str]: + """Return the raw HTTP headers the server received for this request.""" + return dict(get_http_request().headers) + + return server + + +async def test_mcp_method_and_name_headers_reach_server(): + """`Mcp-Method` and `Mcp-Name` set by the SDK client arrive at the server.""" + async with asgi_server(_echo_server(), transport="http") as running_server: + async with running_server.client() as client: + result = await client.call_tool("echo_headers") + + headers = result.data + assert headers["mcp-method"] == "tools/call" + assert headers["mcp-name"] == "echo_headers" + + +async def test_mcp_param_header_reaches_server(): + """An `x-mcp-header` annotated parameter is mirrored into `Mcp-Param-*`. + + The SDK client only emits `Mcp-Param-*` once it has seen the tool's input + schema, so the test lists tools before calling. + """ + async with asgi_server(_echo_server(), transport="http") as running_server: + async with running_server.client() as client: + await client.list_tools() + result = await client.call_tool("echo_headers", {"tenant": "beta-corp"}) + + headers = result.data + assert headers["mcp-param-tenant"] == "beta-corp" + + +async def test_routing_headers_survive_host_origin_protection(): + """The Host/Origin request guard does not strip the routing headers.""" + async with asgi_server( + _echo_server(), + transport="http", + host_origin_protection=True, + allowed_hosts=["*"], + allowed_origins=["*"], + ) as running_server: + async with running_server.client() as client: + await client.list_tools() + result = await client.call_tool("echo_headers", {"tenant": "gamma"}) + + headers = result.data + assert headers["mcp-method"] == "tools/call" + assert headers["mcp-name"] == "echo_headers" + assert headers["mcp-param-tenant"] == "gamma" + + +async def test_x_mcp_header_annotation_survives_schema_generation(): + """FastMCP preserves `x-mcp-header` in a tool's advertised input schema. + + This is the annotation the SDK client reads to decide which arguments to + mirror into `Mcp-Param-*` headers, so it must reach the wire unchanged. + """ + server = _echo_server() + tools = await server._list_tools() + (tool,) = [t for t in tools if t.name == "echo_headers"] + assert tool.parameters["properties"]["tenant"]["x-mcp-header"] == "Tenant" diff --git a/tests/server/middleware/test_caching_guards.py b/tests/server/middleware/test_caching_guards.py new file mode 100644 index 000000000..e552dc49e --- /dev/null +++ b/tests/server/middleware/test_caching_guards.py @@ -0,0 +1,123 @@ +"""Response caching around multi-round-trip asks (SEP-2322). + +A guard component answers a call by *returning* an `InputRequiredResult` — a +request for client input rather than a final answer. Two things follow for +`ResponseCachingMiddleware`, and they apply equally to tools, prompts, and +resources: + +- An ask must never be stored. It carries no content of its own, so caching one + writes an empty result, and every later caller is served that emptiness + instead of being asked the question. +- A continuation leg must bypass the cache entirely. Cache keys are built from + the component's identity and arguments alone, so a continuation shares its key + with a fresh call: reading could hand this leg a prior flow's final answer, and + writing would hand a later fresh caller *this* flow's answer, skipping the + questions altogether. +""" + +import mcp_types + +from fastmcp import Context, FastMCP +from fastmcp.client.client import Client +from fastmcp.client.elicitation import ElicitResult +from fastmcp.server.middleware.caching import ResponseCachingMiddleware + + +def _ask() -> mcp_types.InputRequiredResult: + """The single-question ask every guard in this module returns.""" + params = mcp_types.ElicitRequestFormParams( + message="Which quarter?", + requested_schema={ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + }, + ) + request = mcp_types.ElicitRequest(method="elicitation/create", params=params) + return mcp_types.InputRequiredResult( + result_type="input_required", + input_requests={"q": request}, + ) + + +def _answer(responses: mcp_types.InputResponses) -> str: + """The accepted value for the question `_ask` poses.""" + result = responses["q"] + assert isinstance(result, mcp_types.ElicitResult) + assert result.content is not None + return str(result.content["q"]) + + +async def _handler(message, response_type, params, ctx): + """An elicitation handler that always answers "Q3".""" + return ElicitResult(action="accept", content=response_type(q="Q3")) + + +def cached_guard_server() -> FastMCP: + """A caching server whose tool, prompt, and resource are all guards.""" + mcp = FastMCP("cached-guards") + mcp.add_middleware(ResponseCachingMiddleware()) + + @mcp.tool + async def summarize_tool(ctx: Context) -> str | mcp_types.InputRequiredResult: + if ctx.input_responses is None: + return _ask() + return f"Summary for {_answer(ctx.input_responses)}" + + @mcp.prompt + async def summarize(ctx: Context) -> str | mcp_types.InputRequiredResult: + if ctx.input_responses is None: + return _ask() + return f"Summary for {_answer(ctx.input_responses)}" + + @mcp.resource("report://x") + async def report(ctx: Context) -> str | mcp_types.InputRequiredResult: + if ctx.input_responses is None: + return _ask() + return f"Report for {_answer(ctx.input_responses)}" + + return mcp + + +def guard_client() -> Client: + """A client that answers each round automatically.""" + return Client(cached_guard_server(), mode="auto", elicitation_handler=_handler) + + +class TestGuardsCompleteUnderCaching: + """Each component type drives its loop to a real answer with caching on.""" + + async def test_tool(self): + async with guard_client() as client: + result = await client.call_tool("summarize_tool", {}) + + assert result.data == "Summary for Q3" + + async def test_prompt(self): + async with guard_client() as client: + result = await client.get_prompt("summarize") + + assert result.messages[0].content.text == "Summary for Q3" + + async def test_resource(self): + async with guard_client() as client: + result = await client.read_resource("report://x") + + assert result[0].text == "Report for Q3" + + +class TestAsksAreNotCached: + """A stored ask would poison every later caller.""" + + async def test_second_fresh_flow_is_asked_again(self): + """A second fresh flow must be asked the same question. + + Serving it a cached final answer would skip the component's own + per-round logic — it would receive an answer it never supplied input for. + """ + async with guard_client() as client: + first = await client.get_prompt("summarize") + second = await client.get_prompt("summarize") + + assert first.messages[0].content.text == "Summary for Q3" + assert second.messages[0].content.text == "Summary for Q3" diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py index c625a7d55..715413017 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -147,10 +147,6 @@ def mcp_server(recording_middleware): async def log_tool(context: Context) -> None: await context.info(message="test log") - @mcp.tool - async def sample_tool(context: Context) -> None: - await context.sample("hello") - mcp.add_middleware(recording_middleware) # Register a progress notification handler (v2 API: (ctx, params)). diff --git a/tests/server/middleware/test_middleware_nested.py b/tests/server/middleware/test_middleware_nested.py index cc2eea6c0..374a07cc8 100644 --- a/tests/server/middleware/test_middleware_nested.py +++ b/tests/server/middleware/test_middleware_nested.py @@ -149,10 +149,6 @@ def mcp_server(recording_middleware): async def log_tool(context: Context) -> None: await context.info(message="test log") - @mcp.tool - async def sample_tool(context: Context) -> None: - await context.sample("hello") - mcp.add_middleware(recording_middleware) # Register a progress notification handler (v2 API: (ctx, params)). @@ -205,10 +201,6 @@ class TestNestedMiddlewareHooks: async def log_tool(context: Context) -> None: await context.info(message="test log") - @mcp.tool - async def sample_tool(context: Context) -> None: - await context.sample("hello") - mcp.add_middleware(nested_middleware) return mcp @@ -510,7 +502,7 @@ class TestProxyServer: async with Client(proxy_server) as client: await client.list_tools() - assert TAGS == [{"add-tool"}, set(), set(), set()] + assert TAGS == [{"add-tool"}, set(), set()] class TestToolCallDenial: diff --git a/tests/server/providers/openapi/test_openapi_discriminator.py b/tests/server/providers/openapi/test_openapi_discriminator.py new file mode 100644 index 000000000..9eca4ece0 --- /dev/null +++ b/tests/server/providers/openapi/test_openapi_discriminator.py @@ -0,0 +1,286 @@ +"""Tests for OpenAPI discriminator handling in OpenAPIProvider.""" + +import json +from typing import Any + +import httpx2 +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.server.providers.openapi import OpenAPIProvider + + +def create_openapi_server(openapi_spec: dict, client) -> FastMCP: + """Helper to create a FastMCP server with OpenAPIProvider.""" + mcp = FastMCP("OpenAPI Server") + mcp.add_provider(OpenAPIProvider(openapi_spec=openapi_spec, client=client)) + return mcp + + +def discriminator_spec( + mapping: dict[str, str] | None = None, + body_ref: str = "Pet", +) -> dict[str, Any]: + """A parent schema with a discriminator mapping onto two allOf subtypes.""" + if mapping is None: + mapping = { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog", + } + return { + "openapi": "3.1.0", + "info": {"title": "Pet API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/pets": { + "post": { + "operationId": "create_pet", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": {"$ref": f"#/components/schemas/{body_ref}"} + } + }, + }, + "responses": {"200": {"description": "Created"}}, + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "properties": {"petType": {"type": "string"}}, + "required": ["petType"], + "discriminator": { + "propertyName": "petType", + "mapping": mapping, + }, + }, + "Cat": { + "allOf": [ + {"$ref": "#/components/schemas/Pet"}, + { + "type": "object", + "properties": {"meowVolume": {"type": "integer"}}, + "required": ["meowVolume"], + }, + ] + }, + "Dog": { + "allOf": [ + {"$ref": "#/components/schemas/Pet"}, + { + "type": "object", + "properties": {"packSize": {"type": "integer"}}, + "required": ["packSize"], + }, + ] + }, + } + }, + } + + +def colliding_variant_spec() -> dict[str, Any]: + """Subtypes that disagree about the shape of the discriminator property. + + The parent marks ``kind`` required without declaring it, so each subtype's + own ``const`` is the only schema available for that field. + """ + return { + "openapi": "3.1.0", + "info": {"title": "Pet API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/pets": { + "post": { + "operationId": "create_pet", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Pet"} + } + }, + }, + "responses": {"200": {"description": "Created"}}, + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["kind"], + "discriminator": { + "propertyName": "kind", + "mapping": {"cat": "Cat", "dog": "Dog"}, + }, + }, + "Cat": { + "allOf": [ + {"$ref": "#/components/schemas/Pet"}, + { + "type": "object", + "properties": { + "kind": {"const": "cat"}, + "meowVolume": {"type": "integer"}, + }, + }, + ] + }, + "Dog": { + "allOf": [ + {"$ref": "#/components/schemas/Pet"}, + { + "type": "object", + "properties": { + "kind": {"const": "dog"}, + "packSize": {"type": "integer"}, + }, + }, + ] + }, + } + }, + } + + +def propertyless_variant_spec() -> dict[str, Any]: + """Subtypes that add nothing beyond the parent they compose.""" + spec = discriminator_spec() + for name in ("Cat", "Dog"): + spec["components"]["schemas"][name] = { + "allOf": [{"$ref": "#/components/schemas/Pet"}] + } + return spec + + +async def tool_schema(spec: dict[str, Any]) -> dict[str, Any]: + """Build the server and return the generated input schema for create_pet.""" + async with httpx2.AsyncClient( + transport=httpx2.MockTransport( + lambda request: httpx2.Response(200, json={"ok": True}) + ), + base_url="https://api.example.com", + ) as client: + server = create_openapi_server(spec, client) + async with Client(server) as mcp_client: + tools = await mcp_client.list_tools() + return next(t for t in tools if t.name == "create_pet").input_schema + + +class TestDiscriminatorRequestBodies: + """Subtypes named by a discriminator mapping are flattened in as optional.""" + + async def test_subtype_fields_are_advertised(self): + """Fields reachable only through discriminator.mapping reach the schema.""" + schema = await tool_schema(discriminator_spec()) + + assert schema["properties"].keys() >= {"petType", "meowVolume", "packSize"} + + async def test_subtype_fields_are_optional(self): + """Only the discriminator is required; variant fields never are.""" + schema = await tool_schema(discriminator_spec()) + + assert schema["required"] == ["petType"] + + async def test_discriminator_property_describes_the_variants(self): + """The discriminator names which fields belong to which variant.""" + schema = await tool_schema(discriminator_spec()) + + description = schema["properties"]["petType"]["description"] + assert "meowVolume" in description + assert "packSize" in description + + async def test_discriminator_keyword_is_dropped(self): + """The mapping points at $defs that get pruned, so it cannot survive.""" + schema = await tool_schema(discriminator_spec()) + + assert "discriminator" not in schema + assert "discriminator" not in schema["properties"]["petType"] + + @pytest.mark.parametrize( + "mapping", + [ + pytest.param({"cat": "#/components/schemas/Missing"}, id="missing_ref"), + pytest.param({"cat": "Missing"}, id="missing_name"), + pytest.param({"cat": "https://example.com/Cat"}, id="remote_target"), + pytest.param({"cat": "#/definitions/Cat"}, id="unsupported_pointer"), + ], + ) + async def test_unresolvable_mapping_is_ignored(self, mapping: dict[str, str]): + """An unusable mapping leaves the parent schema as it was.""" + schema = await tool_schema(discriminator_spec(mapping=mapping)) + + assert set(schema["properties"]) == {"petType"} + + async def test_bare_schema_name_mapping_resolves(self): + """Mapping values may be schema names, not just references.""" + schema = await tool_schema( + discriminator_spec(mapping={"cat": "Cat", "dog": "Dog"}) + ) + + assert schema["properties"].keys() >= {"petType", "meowVolume", "packSize"} + + async def test_selected_variant_field_reaches_the_request_body(self): + """The reported failure: meowVolume must reach the upstream API.""" + received: dict[str, object] = {} + + def handler(request): + received["body"] = json.loads(request.content) + return httpx2.Response(200, json={"ok": True}) + + async with httpx2.AsyncClient( + transport=httpx2.MockTransport(handler), + base_url="https://api.example.com", + ) as client: + server = create_openapi_server(discriminator_spec(), client) + async with Client(server) as mcp_client: + result = await mcp_client.call_tool( + "create_pet", {"petType": "cat", "meowVolume": 11} + ) + + assert result.structured_content == {"ok": True} + assert received["body"] == {"petType": "cat", "meowVolume": 11} + + async def test_accepted_values_are_advertised(self): + """The legal tags are named even when no variant adds a field.""" + schema = await tool_schema(propertyless_variant_spec()) + + description = schema["properties"]["petType"]["description"] + assert "'cat'" in description + assert "'dog'" in description + + async def test_propertyless_variant_is_still_named(self): + """A variant adding no fields remains a legal discriminator value.""" + spec = discriminator_spec() + spec["components"]["schemas"]["Dog"] = { + "allOf": [{"$ref": "#/components/schemas/Pet"}] + } + + schema = await tool_schema(spec) + + description = schema["properties"]["petType"]["description"] + assert "'dog'" in description + assert "meowVolume" in description + + async def test_conflicting_variant_schemas_are_unioned(self): + """No variant's constraint may be advertised as if it applied to all.""" + schema = await tool_schema(colliding_variant_spec()) + + kind = schema["properties"]["kind"] + assert [alternative.get("const") for alternative in kind["anyOf"]] == [ + "cat", + "dog", + ] + + async def test_subtype_body_is_unaffected(self): + """A body referencing the child still resolves through allOf only.""" + schema = await tool_schema(discriminator_spec(body_ref="Cat")) + + assert set(schema["properties"]) == {"petType", "meowVolume"} + assert sorted(schema["required"]) == ["meowVolume", "petType"] diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/providers/openapi/test_openapi_features.py index ceabe117d..35ddc4aa0 100644 --- a/tests/server/providers/openapi/test_openapi_features.py +++ b/tests/server/providers/openapi/test_openapi_features.py @@ -1,5 +1,6 @@ """Tests for OpenAPI feature support in OpenAPIProvider.""" +import json from typing import Any from unittest.mock import AsyncMock, Mock @@ -1412,3 +1413,117 @@ class TestMultipartUpload: assert "multipart/form-data" in received["content_type"] assert b"data" in received["body"] + + +class TestAllOfReferenceRequestBodies: + """Request bodies keep fields inherited through an allOf reference.""" + + SPEC = { + "openapi": "3.1.0", + "info": {"title": "Pet API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/pets": { + "post": { + "operationId": "create_pet", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Cat"} + } + }, + }, + "responses": {"200": {"description": "Created"}}, + } + } + }, + "components": { + "schemas": { + "Animal": { + "type": "object", + "properties": {"animalId": {"type": "string"}}, + "required": ["animalId"], + }, + "Pet": { + "allOf": [ + {"$ref": "#/components/schemas/Animal"}, + { + "type": "object", + "properties": {"petType": {"type": "string"}}, + "required": ["petType"], + }, + ] + }, + "Cat": { + "allOf": [ + {"$ref": "#/components/schemas/Pet"}, + { + "type": "object", + "properties": {"meowVolume": {"type": "integer"}}, + "required": ["meowVolume"], + }, + ] + }, + } + }, + } + + async def test_allof_reference_fields_reach_tool_schema_and_request_body(self): + received: dict[str, object] = {} + + def handler(request): + received["body"] = json.loads(request.content) + return httpx2.Response(200, json={"ok": True}) + + async with httpx2.AsyncClient( + transport=httpx2.MockTransport(handler), + base_url="https://api.example.com", + ) as client: + server = create_openapi_server(self.SPEC, client) + async with Client(server) as mcp_client: + tools = await mcp_client.list_tools() + tool = next(tool for tool in tools if tool.name == "create_pet") + assert tool.input_schema["properties"].keys() >= { + "animalId", + "petType", + "meowVolume", + } + + result = await mcp_client.call_tool( + "create_pet", + {"animalId": "a-1", "petType": "cat", "meowVolume": 11}, + ) + + assert result.structured_content == {"ok": True} + assert received["body"] == { + "animalId": "a-1", + "petType": "cat", + "meowVolume": 11, + } + + async def test_allof_reference_request_body_does_not_crash(self): + """Required fields inherited through a reference can be sent together.""" + received: dict[str, object] = {} + + def handler(request): + received["body"] = json.loads(request.content) + return httpx2.Response(200, json={"ok": True}) + + async with httpx2.AsyncClient( + transport=httpx2.MockTransport(handler), + base_url="https://api.example.com", + ) as client: + server = create_openapi_server(self.SPEC, client) + async with Client(server) as mcp_client: + result = await mcp_client.call_tool( + "create_pet", + {"animalId": "a-1", "petType": "cat", "meowVolume": 11}, + ) + + assert result.structured_content == {"ok": True} + assert received["body"] == { + "animalId": "a-1", + "petType": "cat", + "meowVolume": 11, + } diff --git a/tests/server/providers/proxy/test_proxy_client.py b/tests/server/providers/proxy/test_proxy_client.py index bc3b861fc..caa4b9075 100644 --- a/tests/server/providers/proxy/test_proxy_client.py +++ b/tests/server/providers/proxy/test_proxy_client.py @@ -8,6 +8,7 @@ from mcp_types import ( LoggingLevel, ModelHint, ModelPreferences, + Root, TextContent, ) from pydantic import BaseModel, Field @@ -58,6 +59,34 @@ class TestProxyClientEraDefault: assert cast(Client, factory()).mode == "auto" +async def _backend_list_roots(context: Context) -> list[Root]: + """Issue a handshake-era `roots/list` from a backend server. + + `Context` has no `list_roots()`: server-initiated requests are not part of + FastMCP's server API. These helpers reach the SDK session directly to stand + in for a legacy upstream, which is the only thing the proxy relay forwards. + """ + result = await context.session.list_roots() # ty: ignore[deprecated] + return result.roots + + +async def _backend_sample(context: Context) -> str: + """Issue a handshake-era `sampling/createMessage` from a backend server.""" + result = await context.session.create_message( # ty: ignore[deprecated] + messages=[ + SamplingMessage( + role="user", content=TextContent(type="text", text="Hello, world!") + ) + ], + system_prompt="You love FastMCP", + temperature=0.5, + max_tokens=100, + model_preferences=ModelPreferences(hints=[ModelHint(name="gpt-4o")]), + related_request_id=context.origin_request_id, + ) + return result.content.text if isinstance(result.content, TextContent) else "" + + @pytest.fixture def fastmcp_server(): mcp = FastMCP("TestServer") @@ -68,21 +97,13 @@ def fastmcp_server(): @mcp.tool async def list_roots(context: Context) -> list[str]: - roots = await context.list_roots() - return [str(r.uri) for r in roots] + return [str(r.uri) for r in await _backend_list_roots(context)] @mcp.tool async def sampling( context: Context, ) -> str: - result = await context.sample( - "Hello, world!", - system_prompt="You love FastMCP", - temperature=0.5, - max_tokens=100, - model_preferences="gpt-4o", - ) - return result.text or "" + return await _backend_sample(context) @dataclass class Person: @@ -514,17 +535,16 @@ def roots_backend_server(): @mcp.resource("data://roots") async def roots_resource(context: Context) -> list[str]: - roots = await context.list_roots() - return [str(r.uri) for r in roots] + return [str(r.uri) for r in await _backend_list_roots(context)] @mcp.resource("data://roots/{key}") async def roots_template(key: str, context: Context) -> str: - roots = await context.list_roots() + roots = await _backend_list_roots(context) return ", ".join(f"{key}:{r.uri}" for r in roots) @mcp.prompt async def roots_prompt(context: Context) -> str: - roots = await context.list_roots() + roots = await _backend_list_roots(context) return ", ".join(str(r.uri) for r in roots) return mcp diff --git a/tests/server/providers/proxy/test_proxy_server.py b/tests/server/providers/proxy/test_proxy_server.py index 184a63e9a..8971b5ba0 100644 --- a/tests/server/providers/proxy/test_proxy_server.py +++ b/tests/server/providers/proxy/test_proxy_server.py @@ -35,6 +35,7 @@ from fastmcp.tools.tool_transform import ( ) from fastmcp.utilities.http import find_available_port from fastmcp.utilities.tests import run_server_async +from tests.conftest import user_meta USERS = [ {"id": "1", "name": "Alice", "active": True}, @@ -888,7 +889,12 @@ class TestPrompts: result = await client.get_prompt("welcome", {"name": "Alice"}) async with Client(proxy_server) as client: proxy_result = await client.get_prompt("welcome", {"name": "Alice"}) - assert proxy_result == result + # Each server stamps its own `serverInfo` into `_meta` (spec #3002), so + # the proxy's stamp naturally differs from the origin's. Compare the + # relayed payload. + assert proxy_result.model_copy( + update={"meta": user_meta(proxy_result.meta)} + ) == result.model_copy(update={"meta": user_meta(result.meta)}) async def test_render_prompt_calls_prompt(self, proxy_server): async with Client(proxy_server) as client: @@ -942,8 +948,11 @@ class TestPrompts: async with Client(proxy_server) as client: proxy_result = await client.get_prompt("image_prompt") - # The proxy result should match the original exactly - assert proxy_result == result + # The proxy relays the original payload; only the per-server + # `serverInfo` `_meta` stamp differs. + assert proxy_result.model_copy( + update={"meta": user_meta(proxy_result.meta)} + ) == result.model_copy(update={"meta": user_meta(result.meta)}) # Verify the image content is preserved as ImageContent, not JSON text assert isinstance(proxy_result.messages[1].content, mcp_types.ImageContent) assert proxy_result.messages[1].content.data == "iVBORw0KGgoAAAANSUhEUg==" @@ -1554,3 +1563,64 @@ class TestProxyProviderTransportErrors: with pytest.raises(MCPError, match="Client failed to connect"): async with Client(proxy, mode=mode) as client: await client.list_tools() + + +async def test_proxy_preserves_x_mcp_header_annotation(): + """A proxy re-advertises a backend tool's `x-mcp-header` annotation (SEP-2243). + + The routing headers are per-hop: the SDK client regenerates them on each + HTTP request. For `Mcp-Param-*` to be emitted on the proxy->backend hop (and + on the caller->proxy hop), the proxy must carry the backend's `x-mcp-header` + schema annotation through to its own advertised tool schema. + """ + from typing import Annotated + + from pydantic import Field + + backend = FastMCP("Backend") + + @backend.tool + def route( + tenant: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Tenant"})], + ) -> str: + return tenant + + proxy = create_proxy(backend) + async with Client(proxy) as client: + tools = await client.list_tools() + + (tool,) = [t for t in tools if t.name == "route"] + assert tool.input_schema["properties"]["tenant"]["x-mcp-header"] == "Tenant" + + +async def test_proxy_forwards_mcp_param_header_to_modern_http_backend(): + """A proxy in front of a modern Streamable-HTTP backend routes an annotated call (SEP-2243). + + A modern backend validates that an `x-mcp-header` argument is mirrored into an + `Mcp-Param-*` header and rejects the call with `HEADER_MISMATCH` when it is + missing. The SDK client caches the annotation map on `list_tools`, but a + proxied `tools/call` goes straight to `call_tool` on a fresh backend session, + so the proxy must seed the map itself. This exercises the real validating HTTP + hop end to end. + """ + from typing import Annotated + + from pydantic import Field + + backend = FastMCP("Backend") + + @backend.tool + def route( + tenant: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Tenant"})], + ) -> str: + return f"routed:{tenant}" + + async with run_server_async(backend, transport="http") as url: + # mode="auto" negotiates the modern protocol with the HTTP backend, so + # the proxy->backend hop is the validating one. (ProxyClient defaults to + # legacy, which neither emits nor validates these headers.) + proxy = create_proxy(ProxyClient(StreamableHttpTransport(url), mode="auto")) + async with Client(proxy) as client: + result = await client.call_tool("route", {"tenant": "acme"}) + + assert result.data == "routed:acme" diff --git a/tests/server/providers/test_prefab_roundtrip.py b/tests/server/providers/test_prefab_roundtrip.py index 72946910f..05897eef8 100644 --- a/tests/server/providers/test_prefab_roundtrip.py +++ b/tests/server/providers/test_prefab_roundtrip.py @@ -8,22 +8,46 @@ single-server, namespaced mounts, and cross-server mounts. from __future__ import annotations -import json - import pytest from fastmcp import FastMCP, FastMCPApp -from fastmcp.server.providers.addressing import hashed_backend_name +from fastmcp.exceptions import ToolError +from fastmcp.experimental.transforms.code_mode import CodeMode +from fastmcp.server.middleware.tool_injection import ToolInjectionMiddleware +from fastmcp.server.providers.addressing import hash_tool, hashed_backend_name +from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider +from fastmcp.server.transforms.search import RegexSearchTransform +from fastmcp.server.transforms.tool_transform import ToolTransform +from fastmcp.tools.base import Tool +from fastmcp.tools.tool_transform import ToolTransformConfig prefab_ui = pytest.importorskip("prefab_ui") from prefab_ui.actions.mcp import CallTool # noqa: E402 from prefab_ui.components import Button, Column, Text # noqa: E402 +def _tool_refs(payload) -> list[str]: + """Every tool name the rendered UI would call, in document order.""" + refs: list[str] = [] + + def walk(node) -> None: + if isinstance(node, dict): + if node.get("action") == "toolCall" and isinstance(node.get("tool"), str): + refs.append(node["tool"]) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(payload) + return refs + + class TestSingleServerRoundTrip: - async def test_ui_tool_serializes_hashed_peer_reference(self): - """The resolver converts a CallTool string reference to a hashed - name that appears in the tool result's structured_content.""" + async def test_payload_carries_the_servers_own_tool_name(self): + """The renderer is handed a name that exists in this server's + tools/list, not the identity-addressed form.""" app = FastMCPApp("contacts") @app.tool() @@ -41,13 +65,32 @@ class TestSingleServerRoundTrip: result = await server.call_tool("contact_form", {}) assert result.structured_content is not None + assert _tool_refs(result.structured_content) == ["save_contact"] - # The hashed name should appear somewhere in the serialized output. - sc_json = json.dumps(result.structured_content) - expected_hash = hashed_backend_name("contacts", "save_contact") - assert expected_hash in sc_json, ( - f"Expected {expected_hash!r} in structured_content but got: {sc_json[:200]}" - ) + async def test_payload_records_the_identity_behind_each_reference(self): + """The identity-addressed form survives alongside the rewritten name, + so an outer server can re-resolve it — or fall back to it.""" + app = FastMCPApp("contacts") + + @app.tool() + def save_contact(name: str) -> str: + return f"saved {name}" + + @app.ui() + def contact_form() -> Column: + return Column( + children=[Button(label="Save", on_click=CallTool(tool="save_contact"))] + ) + + server = FastMCP("Platform") + server.add_provider(app) + + result = await server.call_tool("contact_form", {}) + assert result.structured_content is not None + names = result.structured_content["_meta"]["fastmcp"]["toolNames"] + assert names == { + "save_contact": hashed_backend_name("contacts", "save_contact") + } async def test_hashed_name_from_result_is_callable(self): """The hashed name that appears in structured_content actually @@ -131,6 +174,470 @@ class TestMountedServerRoundTrip: assert result.content[0].text == "saved Carol" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] +class TestProxiedServerRoundTrip: + """A gateway proxying an app-bearing backend. + + A proxy knows only what crossed the wire, so this is the topology that + breaks if app-only tools are filtered out of tools/list or if the + identity hash is stripped from meta. + """ + + @staticmethod + def _backend() -> FastMCP: + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str) -> str: + return f"saved {name}" + + @app.ui() + def form() -> Text: + return Text(content="Form") + + backend = FastMCP("Backend") + backend.add_provider(app) + return backend + + async def test_app_only_tool_is_forwarded_through_a_proxy(self): + backend = self._backend() + gateway = FastMCP("Gateway") + gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) + + names = [t.name for t in await gateway.list_tools()] + assert "save" in names + + async def test_identity_hash_survives_the_proxy(self): + backend = self._backend() + gateway = FastMCP("Gateway") + gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) + + tool = next(t for t in await gateway.list_tools() if t.name == "save") + assert tool.meta is not None + assert tool.meta["fastmcp"]["tool_hash"] == hash_tool("contacts", "save") + + async def test_backend_tool_callable_by_hash_through_a_proxy(self): + backend = self._backend() + gateway = FastMCP("Gateway") + gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) + + hashed_name = hashed_backend_name("contacts", "save") + result = await gateway.call_tool(hashed_name, {"name": "Dana"}) + assert result.content[0].text == "saved Dana" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_backend_tool_callable_through_a_namespaced_proxy(self): + backend = self._backend() + gateway = FastMCP("Gateway") + gateway.add_provider( + ProxyProvider(lambda: ProxyClient(backend)), namespace="up" + ) + + names = [t.name for t in await gateway.list_tools()] + assert "up_save" in names + + hashed_name = hashed_backend_name("contacts", "save") + result = await gateway.call_tool(hashed_name, {"name": "Erin"}) + assert result.content[0].text == "saved Erin" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_backend_tool_callable_through_chained_proxies(self): + backend = self._backend() + middle = FastMCP("Middle") + middle.add_provider(ProxyProvider(lambda: ProxyClient(backend))) + top = FastMCP("Top") + top.add_provider(ProxyProvider(lambda: ProxyClient(middle))) + + hashed_name = hashed_backend_name("contacts", "save") + result = await top.call_tool(hashed_name, {"name": "Frank"}) + assert result.content[0].text == "saved Frank" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + +class TestLateBoundToolNames: + """The payload is re-addressed on the way out of every FastMCP server. + + Servers unwind innermost-first, so the outermost one rewrites last and its + names — the only ones a client can invoke — are what the renderer receives. + """ + + @staticmethod + def _app(marker: str = "x", app_name: str = "contacts") -> FastMCPApp: + app = FastMCPApp(app_name) + + @app.tool() + def save(name: str) -> str: + return f"[{marker}] saved {name}" + + @app.ui() + def form() -> Column: + return Column( + children=[Button(label="Save", on_click=CallTool(tool="save"))] + ) + + return app + + async def test_namespaced_server_emits_its_namespaced_name(self): + server = FastMCP("Platform") + server.add_provider(self._app(), namespace="crm") + + result = await server.call_tool("crm_form", {}) + assert _tool_refs(result.structured_content) == ["crm_save"] + + async def test_name_accumulates_through_nested_mounts(self): + inner = FastMCP("Inner") + inner.add_provider(self._app(), namespace="a") + mid = FastMCP("Mid") + mid.add_provider(inner, namespace="b") + top = FastMCP("Top") + top.add_provider(mid, namespace="c") + + result = await top.call_tool("c_b_a_form", {}) + assert _tool_refs(result.structured_content) == ["c_b_a_save"] + + async def test_gateway_emits_its_own_name_not_the_backends(self): + backend = FastMCP("Backend") + backend.add_provider(self._app()) + + gateway = FastMCP("Gateway") + gateway.add_provider( + ProxyProvider(lambda: ProxyClient(backend)), namespace="up" + ) + + result = await gateway.call_tool("up_form", {}) + assert _tool_refs(result.structured_content) == ["up_save"] + + async def test_emitted_name_is_callable_on_the_same_server(self): + """The whole point: what the renderer is told to call, it can call.""" + backend = FastMCP("Backend") + backend.add_provider(self._app(marker="be")) + + gateway = FastMCP("Gateway") + gateway.add_provider( + ProxyProvider(lambda: ProxyClient(backend)), namespace="up" + ) + + result = await gateway.call_tool("up_form", {}) + (ref,) = _tool_refs(result.structured_content) + assert ref in [t.name for t in await gateway.list_tools()] + + clicked = await gateway.call_tool(ref, {"name": "alice"}) + assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + @pytest.mark.parametrize( + "transform_factory,expected_listing", + [ + ( + lambda: RegexSearchTransform(), + ["search_tools", "call_tool"], + ), + ( + lambda: CodeMode(), + ["search", "get_schema", "execute"], + ), + ], + ids=["tool-search", "code-mode"], + ) + async def test_survives_a_collapsed_catalog( + self, transform_factory, expected_listing + ): + """Tool search and code mode replace tools/list wholesale, so there is + no better name to bind to. The reference stays identity-addressed and + the hashed path still resolves it.""" + server = FastMCP("Platform") + server.add_provider(self._app(marker="cat")) + server.add_transform(transform_factory()) + + assert [t.name for t in await server.list_tools()] == expected_listing + + result = await server.call_tool("form", {}) + (ref,) = _tool_refs(result.structured_content) + assert ref == hashed_backend_name("contacts", "save") + + clicked = await server.call_tool(ref, {"name": "alice"}) + assert clicked.content[0].text == "[cat] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + @pytest.mark.parametrize( + "compose", + ["siblings", "nested", "prefixing-namespaces"], + ) + async def test_a_duplicated_app_is_not_bound(self, compose): + """One app composed twice leaves no fact in the listing saying which + copy a UI belongs to, so no name is bound and the reference keeps its + identity. Covers copies as siblings, nested inside one subtree, and + under namespaces that prefix one another. + """ + if compose == "nested": + inner = FastMCP("Inner") + inner.add_provider(self._app(marker="A"), namespace="a") + inner.add_provider(self._app(marker="B"), namespace="b") + server = FastMCP("Top") + server.add_provider(inner, namespace="outer") + entry = "outer_a_form" + else: + second = "a_form" if compose == "prefixing-namespaces" else "b" + server = FastMCP("Top") + server.add_provider(self._app(marker="A"), namespace="a") + server.add_provider(self._app(marker="B"), namespace=second) + entry = "a_form" + + result = await server.call_tool(entry, {}) + (ref,) = _tool_refs(result.structured_content) + assert ref == hashed_backend_name("contacts", "save") + + async def test_a_duplicated_app_reports_the_ambiguity(self): + """The unbound reference must fail with a message that names the real + cause, at any depth — a nested duplicate previously surfaced as + `Unknown tool`, sending readers after a missing registration. + """ + inner = FastMCP("Inner") + inner.add_provider(self._app(marker="A"), namespace="a") + inner.add_provider(self._app(marker="B"), namespace="b") + server = FastMCP("Top") + server.add_provider(inner, namespace="outer") + + result = await server.call_tool("outer_a_form", {}) + (ref,) = _tool_refs(result.structured_content) + + with pytest.raises(ToolError, match="composed more than once"): + await server.call_tool(ref, {"name": "alice"}) + + @pytest.mark.parametrize("backend_namespace", [None, "crm"]) + async def test_collapsed_catalog_over_a_proxy(self, backend_namespace): + """The collapsed-catalog fallback has to survive a backend that + renamed its app tools. Nothing named `save` was ever listed across + the wire, so the identity has to resolve against the remote listing + rather than against a name that only exists at the origin. + """ + app = self._app(marker="be") + backend = FastMCP("Backend") + backend.add_provider(app, namespace=backend_namespace) + + gateway = FastMCP("Gateway") + gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) + gateway.add_transform(RegexSearchTransform()) + + entry = f"{backend_namespace}_form" if backend_namespace else "form" + result = await gateway.call_tool(entry, {}) + (ref,) = _tool_refs(result.structured_content) + assert ref == hashed_backend_name("contacts", "save") + + clicked = await gateway.call_tool(ref, {"name": "alice"}) + assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_versions_of_one_tool_are_a_single_target(self): + """Versions are listed individually and share an identity, but they + also share a name that resolves to the highest version on its own. + Only distinct names mean distinct copies of an app. + """ + app = FastMCPApp("contacts") + for version, prefix in (("1.0.0", "v1"), ("2.0.0", "v2")): + + def save(name: str, _prefix: str = prefix) -> str: + return f"{_prefix} saved {name}" + + app.add_tool(Tool.from_function(save, name="save", version=version)) + + @app.ui() + def form() -> Column: + return Column( + children=[Button(label="Save", on_click=CallTool(tool="save"))] + ) + + server = FastMCP("Platform") + server.add_provider(app) + + result = await server.call_tool("form", {}) + (ref,) = _tool_refs(result.structured_content) + assert ref == "save" + + clicked = await server.call_tool(ref, {"name": "alice"}) + assert clicked.content[0].text == "v2 saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_distinct_apps_sharing_a_backend_name(self): + """Identity and name must agree in both directions. Two apps can each + expose `save`: the identities differ and each has one candidate, but + the shared name resolves to only one of them. + """ + server = FastMCP("Platform") + for app_name, entry, marker in ( + ("crm", "crm_ui", "CRM"), + ("billing", "billing_ui", "BILLING"), + ): + app = FastMCPApp(app_name) + + @app.tool() + def save(name: str, _marker: str = marker) -> str: + return f"[{_marker}] saved {name}" + + @app.ui(entry) + def form() -> Column: + return Column( + children=[Button(label="Save", on_click=CallTool(tool="save"))] + ) + + server.add_provider(app) + + result = await server.call_tool("billing_ui", {}) + (ref,) = _tool_refs(result.structured_content) + assert ref == hashed_backend_name("billing", "save") + + async def test_proxy_refuses_a_remote_that_duplicates_an_app(self): + """A remote mounting one app twice sends back two tools claiming one + identity, and the proxy must refuse on the same terms a local + composition would rather than returning whichever came first. + """ + backend = FastMCP("Backend") + backend.add_provider(self._app(marker="A"), namespace="a") + backend.add_provider(self._app(marker="B"), namespace="b") + + gateway = FastMCP("Gateway") + gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) + + with pytest.raises(ToolError, match="composed more than once"): + await gateway.call_tool( + hashed_backend_name("contacts", "save"), {"name": "alice"} + ) + + async def test_middleware_owns_the_names_it_shadows(self): + """Binding describes the listing a client will see, so it has to run + the middleware chain. An injected tool sharing a backend's name owns + that name at call time, and would be invisible to a listing taken + beneath middleware. + """ + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str) -> str: + return f"[APP] saved {name}" + + @app.ui() + def form() -> Column: + return Column( + children=[Button(label="Save", on_click=CallTool(tool="save"))] + ) + + def injected(name: str) -> str: + return f"[INJECTED] saved {name}" + + server = FastMCP("Platform") + server.add_provider(app) + server.add_middleware( + ToolInjectionMiddleware([Tool.from_function(injected, name="save")]) + ) + + result = await server.call_tool("form", {}) + (ref,) = _tool_refs(result.structured_content) + assert ref == hashed_backend_name("contacts", "save") + + clicked = await server.call_tool(ref, {"name": "alice"}) + assert clicked.content[0].text == "[APP] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_middleware_produced_results_are_rebound(self): + """Middleware can answer a call itself, and such a result never + reaches the core dispatch path — so rebinding belongs above the + chain, not inside it. + """ + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str) -> str: + return f"saved {name}" + + @app.ui() + def form() -> Column: + return Column( + children=[Button(label="Save", on_click=CallTool(tool="save"))] + ) + + server = FastMCP("Platform") + server.add_provider(app) + + entry = await server.get_tool("form") + assert entry is not None + server.add_middleware( + ToolInjectionMiddleware([entry.model_copy(update={"name": "injected"})]) + ) + + result = await server.call_tool("injected", {}) + (ref,) = _tool_refs(result.structured_content) + assert ref == "save" + + clicked = await server.call_tool(ref, {"name": "alice"}) + assert clicked.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_a_transform_cannot_unwire_an_app_tool(self): + """A meta override that keeps the identity but drops app visibility + leaves a tool that can be named yet no longer answers to its + identity — which is the only address a collapsed catalog has. + """ + backend = FastMCP("Backend") + backend.add_provider(self._app(marker="be")) + backend.add_transform( + ToolTransform({"save": ToolTransformConfig(meta={"team": "crm"})}) + ) + + transformed = next(t for t in await backend.list_tools() if t.name == "save") + assert transformed.meta is not None + assert transformed.meta["ui"]["visibility"] == ["app"] + assert transformed.meta["team"] == "crm" + + gateway = FastMCP("Gateway") + gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) + gateway.add_transform(RegexSearchTransform()) + + result = await gateway.call_tool("form", {}) + (ref,) = _tool_refs(result.structured_content) + assert ref == hashed_backend_name("contacts", "save") + + clicked = await gateway.call_tool(ref, {"name": "alice"}) + assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_duplicate_copies_are_not_collapsed_by_a_shared_name(self): + """Copies whose backends collide on a name are the worst case, not the + safe one: two components become indistinguishable. Counting names + alone would see a single unambiguous target and bind to it. + """ + server = FastMCP("Platform") + for entry, marker in (("form_a", "A"), ("form_b", "B")): + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str, _marker: str = marker) -> str: + return f"[{_marker}] saved {name}" + + @app.ui(entry) + def form() -> Column: + return Column( + children=[Button(label="Save", on_click=CallTool(tool="save"))] + ) + + server.add_provider(app) + + listed = await server.list_tools() + assert [t.key for t in listed].count("tool:save@") == 2 + + result = await server.call_tool("form_b", {}) + (ref,) = _tool_refs(result.structured_content) + assert ref == hashed_backend_name("contacts", "save") + + async def test_unresolvable_identity_is_restored(self): + """An inner server binds to a name that means nothing further out, so + a reference this server cannot resolve is restored to its identity + rather than left — a stranded name has no route back, an identity does. + """ + app = FastMCPApp("contacts") + + @app.ui() + def form() -> Column: + return Column( + children=[Button(label="Go", on_click=CallTool(tool="not_registered"))] + ) + + server = FastMCP("Platform") + server.add_provider(app) + + result = await server.call_tool("form", {}) + (ref,) = _tool_refs(result.structured_content) + assert ref == hashed_backend_name("contacts", "not_registered") + + class TestDynamicToolAdd: async def test_tool_added_after_first_call_is_reachable(self): """Tools added to an already-mounted app after the first call @@ -157,10 +664,9 @@ class TestDynamicToolAdd: class TestCollision: - async def test_same_app_name_same_tool_name_first_wins(self): - """Two apps with the same name and same tool name: the hash is - identical, so get_tool_by_hash returns the first match. This is - the same first-match behavior the old get_app_tool had.""" + async def test_distinct_hashes_resolve_independently(self): + """Two apps sharing a name but with different tool names hash + differently, so each tool resolves to itself.""" app_a = FastMCPApp("shared") app_b = FastMCPApp("shared") @@ -172,14 +678,67 @@ class TestCollision: def save_b(name: str) -> str: return f"from B: {name}" - # Register under a different local tool name to avoid - # actual collision at the provider level. The hash collision - # only happens when both app name AND tool name match. - # This test just verifies one app's tool is reachable. server = FastMCP("Platform") server.add_provider(app_a) server.add_provider(app_b) - hashed_name = hashed_backend_name("shared", "save") - result = await server.call_tool(hashed_name, {"name": "Eve"}) + result = await server.call_tool( + hashed_backend_name("shared", "save"), {"name": "Eve"} + ) assert result.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + result_b = await server.call_tool( + hashed_backend_name("shared", "save_b"), {"name": "Eve"} + ) + assert result_b.content[0].text == "from B: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_ambiguous_identity_raises_rather_than_guessing(self): + """The same app composed into two branches yields two tools with one + identity. Routing to either would silently execute the wrong branch's + tool, so the call is refused.""" + server = FastMCP("Platform") + for marker, namespace in (("A", "a"), ("B", "b")): + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str, _marker: str = marker) -> str: + return f"from {_marker}: {name}" + + server.add_provider(app, namespace=namespace) + + with pytest.raises(ToolError, match="Ambiguous app tool"): + await server.call_tool( + hashed_backend_name("contacts", "save"), {"name": "Eve"} + ) + + async def test_distinct_app_names_route_independently_through_a_gateway(self): + """The multi-tenant gateway shape: distinct app names stay unambiguous + no matter how many backends sit behind one proxy.""" + + def backend(marker: str, app_name: str) -> FastMCP: + app = FastMCPApp(app_name) + + @app.tool() + def save(name: str) -> str: + return f"from {marker}: {name}" + + server = FastMCP(f"Backend-{marker}") + server.add_provider(app) + return server + + first = backend("A", "crm") + second = backend("B", "billing") + + gateway = FastMCP("Gateway") + gateway.add_provider(ProxyProvider(lambda: ProxyClient(first)), namespace="a") + gateway.add_provider(ProxyProvider(lambda: ProxyClient(second)), namespace="b") + + result_a = await gateway.call_tool( + hashed_backend_name("crm", "save"), {"name": "Eve"} + ) + assert result_a.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + result_b = await gateway.call_tool( + hashed_backend_name("billing", "save"), {"name": "Eve"} + ) + assert result_b.content[0].text == "from B: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] diff --git a/tests/server/providers/test_skills_provider.py b/tests/server/providers/test_skills_provider.py index 051f68fdb..cfc03e7d2 100644 --- a/tests/server/providers/test_skills_provider.py +++ b/tests/server/providers/test_skills_provider.py @@ -12,7 +12,6 @@ from fastmcp.server.providers.skills import ( ClaudeSkillsProvider, SkillProvider, SkillsDirectoryProvider, - SkillsProvider, ) from fastmcp.server.providers.skills._common import parse_frontmatter from fastmcp.server.providers.skills.skill_provider import SkillFileResource @@ -724,13 +723,6 @@ description: Second occurrence assert resources == [] -class TestSkillsProviderAlias: - """Test that SkillsProvider is a backwards-compatible alias.""" - - def test_skills_provider_is_alias(self): - assert SkillsProvider is SkillsDirectoryProvider - - class TestClaudeSkillsProvider: def test_default_root_is_claude_skills_dir(self, tmp_path: Path, monkeypatch): # Mock Path.home() to return a temp path (use tmp_path for cross-platform compatibility) diff --git a/tests/server/sampling/__init__.py b/tests/server/sampling/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/server/sampling/test_prepare_tools.py b/tests/server/sampling/test_prepare_tools.py deleted file mode 100644 index b0639b492..000000000 --- a/tests/server/sampling/test_prepare_tools.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Tests for prepare_tools helper function.""" - -import pytest - -from fastmcp.server.sampling.run import prepare_tools -from fastmcp.server.sampling.sampling_tool import SamplingTool -from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool_transform import ArgTransform, TransformedTool - - -class TestPrepareTools: - """Tests for prepare_tools().""" - - def test_prepare_tools_with_none(self): - """Test that None returns None.""" - result = prepare_tools(None) - assert result is None - - def test_prepare_tools_with_sampling_tool(self): - """Test that SamplingTool instances pass through.""" - - def search(query: str) -> str: - return f"Results: {query}" - - sampling_tool = SamplingTool.from_function(search) - result = prepare_tools([sampling_tool]) - - assert result is not None - assert len(result) == 1 - assert result[0] is sampling_tool - - def test_prepare_tools_with_function(self): - """Test that plain functions are converted.""" - - def search(query: str) -> str: - """Search function.""" - return f"Results: {query}" - - result = prepare_tools([search]) - - assert result is not None - assert len(result) == 1 - assert isinstance(result[0], SamplingTool) - assert result[0].name == "search" - - def test_prepare_tools_with_function_tool(self): - """Test that FunctionTool instances are converted.""" - - def search(query: str) -> str: - """Search the web.""" - return f"Results: {query}" - - function_tool = FunctionTool.from_function(search) - result = prepare_tools([function_tool]) - - assert result is not None - assert len(result) == 1 - assert isinstance(result[0], SamplingTool) - assert result[0].name == "search" - assert result[0].description == "Search the web." - - def test_prepare_tools_with_transformed_tool(self): - """Test that TransformedTool instances are converted.""" - - def original(query: str) -> str: - """Original tool.""" - return f"Results: {query}" - - function_tool = FunctionTool.from_function(original) - transformed_tool = TransformedTool.from_tool( - function_tool, - name="search_v2", - transform_args={"query": ArgTransform(name="q")}, - ) - - result = prepare_tools([transformed_tool]) - - assert result is not None - assert len(result) == 1 - assert isinstance(result[0], SamplingTool) - assert result[0].name == "search_v2" - assert "q" in result[0].parameters.get("properties", {}) - - def test_prepare_tools_with_mixed_types(self): - """Test that mixed tool types are all converted.""" - - def plain_fn(x: int) -> int: - return x * 2 - - def fn_for_tool(y: int) -> int: - return y * 3 - - function_tool = FunctionTool.from_function(fn_for_tool) - sampling_tool = SamplingTool.from_function(lambda z: z * 4, name="lambda_tool") - - result = prepare_tools([plain_fn, function_tool, sampling_tool]) - - assert result is not None - assert len(result) == 3 - assert all(isinstance(t, SamplingTool) for t in result) - - def test_prepare_tools_with_invalid_type(self): - """Test that invalid types raise TypeError.""" - - with pytest.raises(TypeError, match="Expected SamplingTool, FunctionTool"): - prepare_tools(["not a tool"]) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - - def test_prepare_tools_empty_list(self): - """Test that empty list returns None.""" - result = prepare_tools([]) - assert result is None diff --git a/tests/server/sampling/test_sampling_tool.py b/tests/server/sampling/test_sampling_tool.py deleted file mode 100644 index 792161ea3..000000000 --- a/tests/server/sampling/test_sampling_tool.py +++ /dev/null @@ -1,440 +0,0 @@ -"""Tests for SamplingTool.""" - -import pytest -from mcp.server.auth.middleware.auth_context import auth_context_var -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser - -from fastmcp.exceptions import AuthorizationError -from fastmcp.server.auth import AccessToken, require_scopes -from fastmcp.server.context import _current_transport -from fastmcp.server.sampling import SamplingTool -from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool_transform import ArgTransform, TransformedTool - - -class TestSamplingToolFromFunction: - """Tests for SamplingTool.from_function().""" - - def test_from_simple_function(self): - def search(query: str) -> str: - """Search the web.""" - return f"Results for: {query}" - - tool = SamplingTool.from_function(search) - - assert tool.name == "search" - assert tool.description == "Search the web." - assert "query" in tool.parameters.get("properties", {}) - assert tool.fn is search - - def test_from_function_with_overrides(self): - def search(query: str) -> str: - return f"Results for: {query}" - - tool = SamplingTool.from_function( - search, - name="web_search", - description="Search the internet", - ) - - assert tool.name == "web_search" - assert tool.description == "Search the internet" - - def test_from_lambda_requires_name(self): - with pytest.raises(ValueError, match="must provide a name for lambda"): - SamplingTool.from_function(lambda x: x) - - def test_from_lambda_with_name(self): - tool = SamplingTool.from_function(lambda x: x * 2, name="double") - - assert tool.name == "double" - - def test_from_async_function(self): - async def async_search(query: str) -> str: - """Async search.""" - return f"Async results for: {query}" - - tool = SamplingTool.from_function(async_search) - - assert tool.name == "async_search" - assert tool.description == "Async search." - - def test_multiple_parameters(self): - def search(query: str, limit: int = 10, include_images: bool = False) -> str: - """Search with options.""" - return f"Results for: {query}" - - tool = SamplingTool.from_function(search) - props = tool.parameters.get("properties", {}) - - assert "query" in props - assert "limit" in props - assert "include_images" in props - - -class TestSamplingToolRun: - """Tests for SamplingTool.run().""" - - async def test_run_sync_function(self): - def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - - tool = SamplingTool.from_function(add) - result = await tool.run({"a": 2, "b": 3}) - assert result == 5 - - async def test_run_async_function(self): - async def async_add(a: int, b: int) -> int: - """Add two numbers asynchronously.""" - return a + b - - tool = SamplingTool.from_function(async_add) - result = await tool.run({"a": 2, "b": 3}) - assert result == 5 - - async def test_run_with_no_arguments(self): - def get_value() -> str: - """Return a fixed value.""" - return "hello" - - tool = SamplingTool.from_function(get_value) - result = await tool.run() - assert result == "hello" - - async def test_run_with_none_arguments(self): - def get_value() -> str: - """Return a fixed value.""" - return "hello" - - tool = SamplingTool.from_function(get_value) - result = await tool.run(None) - assert result == "hello" - - -class TestSamplingToolSDKConversion: - """Tests for SamplingTool._to_sdk_tool() internal method.""" - - def test_to_sdk_tool(self): - def search(query: str) -> str: - """Search the web.""" - return f"Results for: {query}" - - tool = SamplingTool.from_function(search) - sdk_tool = tool._to_sdk_tool() - - assert sdk_tool.name == "search" - assert sdk_tool.description == "Search the web." - assert "query" in sdk_tool.input_schema.get("properties", {}) - - -class TestSamplingToolFromCallableTool: - """Tests for SamplingTool.from_callable_tool().""" - - def test_from_function_tool(self): - """Test converting a FunctionTool to SamplingTool.""" - - def search(query: str) -> str: - """Search the web.""" - return f"Results for: {query}" - - function_tool = FunctionTool.from_function(search) - sampling_tool = SamplingTool.from_callable_tool(function_tool) - - assert sampling_tool.name == "search" - assert sampling_tool.description == "Search the web." - assert "query" in sampling_tool.parameters.get("properties", {}) - # fn is now a wrapper that calls tool.run() for proper result processing - assert callable(sampling_tool.fn) - - def test_from_function_tool_with_overrides(self): - """Test converting FunctionTool with name/description overrides.""" - - def search(query: str) -> str: - """Search the web.""" - return f"Results for: {query}" - - function_tool = FunctionTool.from_function(search) - sampling_tool = SamplingTool.from_callable_tool( - function_tool, - name="web_search", - description="Search the internet", - ) - - assert sampling_tool.name == "web_search" - assert sampling_tool.description == "Search the internet" - - def test_from_transformed_tool(self): - """Test converting a TransformedTool to SamplingTool.""" - - def original(query: str, limit: int) -> str: - """Original tool.""" - return f"Results for: {query} (limit: {limit})" - - function_tool = FunctionTool.from_function(original) - transformed_tool = TransformedTool.from_tool( - function_tool, - name="search_transformed", - transform_args={"query": ArgTransform(name="q")}, - ) - - sampling_tool = SamplingTool.from_callable_tool(transformed_tool) - - assert sampling_tool.name == "search_transformed" - assert sampling_tool.description == "Original tool." - # The transformed tool should have 'q' instead of 'query' - assert "q" in sampling_tool.parameters.get("properties", {}) - assert "limit" in sampling_tool.parameters.get("properties", {}) - - async def test_from_function_tool_execution(self): - """Test that converted FunctionTool executes correctly.""" - - def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - - function_tool = FunctionTool.from_function(add) - sampling_tool = SamplingTool.from_callable_tool(function_tool) - - result = await sampling_tool.run({"a": 2, "b": 3}) - assert result == 5 - - async def test_from_transformed_tool_execution(self): - """Test that converted TransformedTool executes correctly.""" - - def multiply(x: int, y: int) -> int: - """Multiply two numbers.""" - return x * y - - function_tool = FunctionTool.from_function(multiply) - transformed_tool = TransformedTool.from_tool( - function_tool, - transform_args={"x": ArgTransform(name="a"), "y": ArgTransform(name="b")}, - ) - - sampling_tool = SamplingTool.from_callable_tool(transformed_tool) - - # Use the transformed parameter names - result = await sampling_tool.run({"a": 3, "b": 4}) - # Result should be unwrapped from ToolResult - assert result == 12 - - def test_from_invalid_tool_type(self): - """Test that from_callable_tool rejects non-tool objects.""" - - class NotATool: - pass - - with pytest.raises( - TypeError, - match="Expected FunctionTool or TransformedTool", - ): - SamplingTool.from_callable_tool(NotATool()) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - - def test_from_plain_function_fails(self): - """Test that plain functions are rejected by from_callable_tool.""" - - def my_function(): - pass - - with pytest.raises(TypeError, match="Expected FunctionTool or TransformedTool"): - SamplingTool.from_callable_tool(my_function) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - - async def test_from_function_tool_with_output_schema(self): - """Test that FunctionTool with output_schema is handled correctly.""" - - def search(query: str) -> dict: - """Search for something.""" - return {"results": ["item1", "item2"], "count": 2} - - # Create FunctionTool with x-fastmcp-wrap-result - function_tool = FunctionTool.from_function( - search, - output_schema={ - "type": "object", - "properties": { - "results": {"type": "array"}, - "count": {"type": "integer"}, - }, - "x-fastmcp-wrap-result": True, - }, - ) - - sampling_tool = SamplingTool.from_callable_tool(function_tool) - - # Run the tool - should unwrap the {"result": {...}} wrapper - result = await sampling_tool.run({"query": "test"}) - - # Should get the unwrapped dict, not ToolResult - assert isinstance(result, dict) - assert result == {"results": ["item1", "item2"], "count": 2} - - async def test_from_function_tool_without_wrap_result(self): - """Test that FunctionTool without x-fastmcp-wrap-result is handled correctly.""" - - def get_data() -> dict: - """Get some data.""" - return {"status": "ok", "value": 42} - - # Create FunctionTool with output_schema but no wrap-result flag - function_tool = FunctionTool.from_function( - get_data, - output_schema={ - "type": "object", - "properties": { - "status": {"type": "string"}, - "value": {"type": "integer"}, - }, - }, - ) - - sampling_tool = SamplingTool.from_callable_tool(function_tool) - - # Run the tool - should return structured_content directly - result = await sampling_tool.run({}) - - assert isinstance(result, dict) - assert result == {"status": "ok", "value": 42} - - -class TestSamplingToolAuthEnforcement: - """Tests that auth-protected tools enforce auth when used via sampling.""" - - async def test_auth_protected_tool_blocked_without_token(self): - """An auth-protected tool wrapped as SamplingTool must reject - calls when no valid token is present in a non-stdio transport.""" - - def secret_action() -> str: - """Do something privileged.""" - return "secret" - - function_tool = FunctionTool.from_function( - secret_action, - auth=require_scopes("admin"), - ) - sampling_tool = SamplingTool.from_callable_tool(function_tool) - - transport_token = _current_transport.set("streamable-http") - try: - with pytest.raises(AuthorizationError, match="insufficient permissions"): - await sampling_tool.run({}) - finally: - _current_transport.reset(transport_token) - - async def test_auth_protected_tool_blocked_with_wrong_scopes(self): - """An auth-protected tool rejects calls when the token lacks - the required scopes.""" - - def secret_action() -> str: - """Do something privileged.""" - return "secret" - - function_tool = FunctionTool.from_function( - secret_action, - auth=require_scopes("admin"), - ) - sampling_tool = SamplingTool.from_callable_tool(function_tool) - - token = AccessToken( - token="test", - client_id="c", - scopes=["read"], - expires_at=None, - claims={}, - ) - transport_token = _current_transport.set("streamable-http") - auth_token = auth_context_var.set(AuthenticatedUser(token)) - try: - with pytest.raises(AuthorizationError, match="insufficient permissions"): - await sampling_tool.run({}) - finally: - auth_context_var.reset(auth_token) - _current_transport.reset(transport_token) - - async def test_auth_protected_tool_allowed_with_correct_scopes(self): - """An auth-protected tool succeeds when the token has the - required scopes.""" - - def secret_action() -> str: - """Do something privileged.""" - return "secret" - - function_tool = FunctionTool.from_function( - secret_action, - auth=require_scopes("admin"), - ) - sampling_tool = SamplingTool.from_callable_tool(function_tool) - - token = AccessToken( - token="test", - client_id="c", - scopes=["admin"], - expires_at=None, - claims={}, - ) - transport_token = _current_transport.set("streamable-http") - auth_token = auth_context_var.set(AuthenticatedUser(token)) - try: - result = await sampling_tool.run({}) - assert result == "secret" - finally: - auth_context_var.reset(auth_token) - _current_transport.reset(transport_token) - - async def test_auth_protected_tool_skipped_on_stdio(self): - """Auth checks are skipped for stdio transport, matching - server dispatcher behavior.""" - - def secret_action() -> str: - """Do something privileged.""" - return "secret" - - function_tool = FunctionTool.from_function( - secret_action, - auth=require_scopes("admin"), - ) - sampling_tool = SamplingTool.from_callable_tool(function_tool) - - transport_token = _current_transport.set("stdio") - try: - result = await sampling_tool.run({}) - assert result == "secret" - finally: - _current_transport.reset(transport_token) - - async def test_tool_without_auth_runs_normally(self): - """Tools without auth still run without any auth context.""" - - def public_action() -> str: - """Do something public.""" - return "public" - - function_tool = FunctionTool.from_function(public_action) - sampling_tool = SamplingTool.from_callable_tool(function_tool) - - result = await sampling_tool.run({}) - assert result == "public" - - async def test_auth_protected_transformed_tool_blocked(self): - """Auth checks also apply to TransformedTools with auth.""" - - def secret_action(x: int) -> int: - """Privileged computation.""" - return x * 2 - - function_tool = FunctionTool.from_function( - secret_action, - auth=require_scopes("compute"), - ) - transformed_tool = TransformedTool.from_tool( - function_tool, - transform_args={"x": ArgTransform(name="value")}, - ) - sampling_tool = SamplingTool.from_callable_tool(transformed_tool) - - transport_token = _current_transport.set("streamable-http") - try: - with pytest.raises(AuthorizationError, match="insufficient permissions"): - await sampling_tool.run({"value": 5}) - finally: - _current_transport.reset(transport_token) diff --git a/tests/server/telemetry/test_provider_tracing.py b/tests/server/telemetry/test_provider_tracing.py index 7118044a2..50c33f8fe 100644 --- a/tests/server/telemetry/test_provider_tracing.py +++ b/tests/server/telemetry/test_provider_tracing.py @@ -4,7 +4,9 @@ from __future__ import annotations from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from fastmcp import FastMCP +from fastmcp import Client, Context, FastMCP +from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient +from fastmcp.telemetry import TRACE_PARENT_KEY class TestFastMCPProviderTracing: @@ -131,3 +133,53 @@ class TestProviderSpanHierarchy: assert child_span.parent is not None assert delegate_span.parent.span_id == parent_span.context.span_id assert child_span.parent.span_id == delegate_span.context.span_id + + +class TestModernProxyTracePropagation: + """A modern proxy relays resources and prompts through the low-level + session so a backend guard's ask can surface (SEP-2322). That path skips + the high-level client's trace injection, so the relay must stamp the + outgoing `_meta` itself — otherwise every modern proxy read breaks the + distributed trace, not only the guard rounds.""" + + @staticmethod + def _backend(seen: dict[str, dict]) -> FastMCP: + backend = FastMCP("trace-backend") + + def record(kind: str, ctx: Context) -> None: + rc = ctx.request_context + seen[kind] = dict(rc.meta) if rc is not None and rc.meta else {} + + @backend.resource("data://x") + async def concrete(ctx: Context) -> str: + record("resource", ctx) + return "ok" + + @backend.resource("data://{part}/y") + async def templated(part: str, ctx: Context) -> str: + record("template", ctx) + return "ok" + + @backend.prompt + async def greet(ctx: Context) -> str: + record("prompt", ctx) + return "ok" + + return backend + + async def test_traceparent_reaches_backend( + self, trace_exporter: InMemorySpanExporter + ): + seen: dict[str, dict] = {} + proxy = FastMCPProxy( + client_factory=lambda: ProxyClient(self._backend(seen), mode="auto") + ) + + async with Client(proxy, mode="auto") as client: + await client.read_resource("data://x") + await client.read_resource("data://p/y") + await client.get_prompt("greet") + + assert TRACE_PARENT_KEY in seen["resource"] + assert TRACE_PARENT_KEY in seen["template"] + assert TRACE_PARENT_KEY in seen["prompt"] diff --git a/tests/server/telemetry/test_sampling_tracing.py b/tests/server/telemetry/test_sampling_tracing.py deleted file mode 100644 index ffd3f19f8..000000000 --- a/tests/server/telemetry/test_sampling_tracing.py +++ /dev/null @@ -1,881 +0,0 @@ -"""Tracing coverage for sampling create_message and tool-execution spans. - -Regression focus: the `sampling create_message` span is created with -`record_exception=False, set_status_on_exception=False` and records the -exception manually in its `except` block. A failed sampling call must -therefore produce exactly ONE exception event, not two. - -`ctx.sample` requires the server to send a request down to the client, which -only the older protocol's back-channel supports, so every client below pins -`mode="legacy"`. -""" - -from __future__ import annotations - -import pytest -from mcp_types import TextContent -from opentelemetry.context import Context as OTelContext -from opentelemetry.sdk.trace import ( - ReadableSpan, - Span, - SpanLimits, - SpanProcessor, - TracerProvider, -) -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult -from opentelemetry.trace import StatusCode -from opentelemetry.util import types as otel_types - -from fastmcp import Client, Context, FastMCP -from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams - - -class OnStartRecorder(SpanProcessor): - def __init__(self) -> None: - self.attributes: dict[str, dict[str, object]] = {} - - def on_start(self, span: Span, parent_context: OTelContext | None = None) -> None: - self.attributes[span.name] = dict(span.attributes or {}) - - -class NonForwardingSampler(Sampler): - """Samples every span but never forwards the attributes it was handed. - - See `tests/telemetry/test_span_attributes.py` for the full explanation: - OTel's `Tracer.start_span` builds the finished span from - `sampling_result.attributes`, not from the `attributes` kwarg passed to - `start_as_current_span`, so a custom sampler like this one reproduces the - regression where a non-forwarding sampler silently drops FastMCP's - attributes. - """ - - def should_sample( - self, - parent_context: OTelContext | None, - trace_id: int, - name: str, - kind: object = None, - attributes: object = None, - links: object = None, - trace_state: object = None, - ) -> SamplingResult: - return SamplingResult(Decision.RECORD_AND_SAMPLE) - - def get_description(self) -> str: - return "NonForwardingSampler" - - -class RedactingSampler(Sampler): - """Forwards the attributes it receives, but replaces `mcp.method.name`. - - See `tests/telemetry/test_span_attributes.py` for the full explanation: - a sampler can legitimately keep an attribute FastMCP set while replacing - its value (e.g. redacting the method name for privacy), and that decision - must survive the restore step. - """ - - def should_sample( - self, - parent_context: OTelContext | None, - trace_id: int, - name: str, - kind: object = None, - attributes: otel_types.Attributes = None, - links: object = None, - trace_state: object = None, - ) -> SamplingResult: - forwarded = dict(attributes or {}) - forwarded["mcp.method.name"] = "REDACTED" - return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=forwarded) - - def get_description(self) -> str: - return "RedactingSampler" - - -class AttributeAddingSampler(Sampler): - """Forwards the attributes it receives unchanged and adds its own.""" - - def should_sample( - self, - parent_context: OTelContext | None, - trace_id: int, - name: str, - kind: object = None, - attributes: otel_types.Attributes = None, - links: object = None, - trace_state: object = None, - ) -> SamplingResult: - forwarded = dict(attributes or {}) - forwarded["sampling.policy"] = "always_on" - return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=forwarded) - - def get_description(self) -> str: - return "AttributeAddingSampler" - - -class FilteringSampler(Sampler): - """Discards every attribute it receives and substitutes its own. - - See `tests/telemetry/test_span_attributes.py` for the full explanation: - a sampler can legitimately supply only its own attributes (e.g. to strip - component names or resource URIs for privacy or cardinality control), - and the restore step must not reintroduce FastMCP's attributes in that - case — doing so would defeat the filter. - """ - - def should_sample( - self, - parent_context: OTelContext | None, - trace_id: int, - name: str, - kind: object = None, - attributes: otel_types.Attributes = None, - links: object = None, - trace_state: object = None, - ) -> SamplingResult: - return SamplingResult( - Decision.RECORD_AND_SAMPLE, attributes={"sampling.policy": "filtered"} - ) - - def get_description(self) -> str: - return "FilteringSampler" - - -@pytest.fixture -def on_start_recorder( - monkeypatch: pytest.MonkeyPatch, - trace_exporter: InMemorySpanExporter, -) -> OnStartRecorder: - recorder = OnStartRecorder() - provider = TracerProvider() - provider.add_span_processor(recorder) - provider.add_span_processor(SimpleSpanProcessor(trace_exporter)) - tracer = provider.get_tracer("test") - monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer) - return recorder - - -def _spans_named(exporter: InMemorySpanExporter, name: str): - return [s for s in exporter.get_finished_spans() if s.name == name] - - -def _exception_events(span): - return [e for e in span.events if e.name == "exception"] - - -class TestSamplingCreateMessageSpan: - async def test_success_creates_span_with_attributes( - self, - trace_exporter: InMemorySpanExporter, - on_start_recorder: OnStartRecorder, - ): - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> str: - return "sampled-text" - - mcp = FastMCP("sampling-server") - - @mcp.tool - async def ask(question: str, context: Context) -> str: - result = await context.sample(messages=question) - return result.text or "" - - async with Client( - mcp, mode="legacy", sampling_handler=sampling_handler - ) as client: - await client.call_tool("ask", {"question": "hi"}) - - spans = _spans_named(trace_exporter, "sampling create_message") - assert len(spans) == 1 - span = spans[0] - assert span.attributes is not None - assert span.attributes["mcp.method.name"] == "sampling/createMessage" - assert span.attributes["fastmcp.server.name"] == "sampling-server" - assert on_start_recorder.attributes["sampling create_message"] == { - "mcp.method.name": "sampling/createMessage", - "fastmcp.server.name": "sampling-server", - } - # Success path must not record any exception. - assert _exception_events(span) == [] - assert span.status.status_code != StatusCode.ERROR - - async def test_failure_records_exception_exactly_once( - self, trace_exporter: InMemorySpanExporter - ): - """Regression: span created with record_exception=False so the manual - record_exception in the except block fires exactly once (no duplicate - exception events from OTel auto-recording on `with` exit).""" - - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> str: - raise RuntimeError("sampling boom") - - mcp = FastMCP("sampling-server") - - @mcp.tool - async def ask(question: str, context: Context) -> str: - result = await context.sample(messages=question) - return result.text or "" - - with pytest.raises(Exception): - async with Client( - mcp, mode="legacy", sampling_handler=sampling_handler - ) as client: - await client.call_tool("ask", {"question": "hi"}) - - spans = _spans_named(trace_exporter, "sampling create_message") - assert len(spans) == 1 - span = spans[0] - assert span.status.status_code == StatusCode.ERROR - assert span.attributes is not None - assert "error.type" in span.attributes - # The whole point of the fix: exactly one exception event. - assert len(_exception_events(span)) == 1 - - -class TestSamplingToolSpan: - async def test_tool_error_span_records_exception_once( - self, - trace_exporter: InMemorySpanExporter, - on_start_recorder: OnStartRecorder, - ): - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - call_count = 0 - - def boom_tool() -> str: - raise ValueError("tool exploded") - - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - if call_count == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="boom_tool", - input={}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="done")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def driver(context: Context) -> str: - result = await context.sample(messages="go", tools=[boom_tool]) - return result.text or "" - - async with Client(mcp) as client: - await client.call_tool("driver", {}) - - spans = _spans_named(trace_exporter, "sampling tool boom_tool") - assert len(spans) == 1 - span = spans[0] - assert span.status.status_code == StatusCode.ERROR - assert span.attributes is not None - assert span.attributes["gen_ai.tool.name"] == "boom_tool" - assert on_start_recorder.attributes["sampling tool boom_tool"] == { - "gen_ai.tool.name": "boom_tool", - "fastmcp.tool.use_id": "call_1", - } - assert "error.type" in span.attributes - # Tool spans catch-and-convert (no re-raise), so OTel auto-recording - # never fires; the manual record_exception must fire exactly once. - assert len(_exception_events(span)) == 1 - - -class TestAttributesSurviveANonForwardingSampler: - """Regression: `sampling create_message` and `sampling tool ...` spans - must keep FastMCP's attributes even when the configured Sampler doesn't - forward the `attributes` it was handed to its `SamplingResult`. - """ - - @pytest.fixture - def non_forwarding_recorder( - self, - monkeypatch: pytest.MonkeyPatch, - trace_exporter: InMemorySpanExporter, - ) -> OnStartRecorder: - recorder = OnStartRecorder() - provider = TracerProvider(sampler=NonForwardingSampler()) - provider.add_span_processor(recorder) - provider.add_span_processor(SimpleSpanProcessor(trace_exporter)) - tracer = provider.get_tracer("test") - monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer) - return recorder - - async def test_create_message_span_keeps_attributes( - self, - trace_exporter: InMemorySpanExporter, - non_forwarding_recorder: OnStartRecorder, - ): - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> str: - return "sampled-text" - - mcp = FastMCP("sampling-server") - - @mcp.tool - async def ask(question: str, context: Context) -> str: - result = await context.sample(messages=question) - return result.text or "" - - async with Client( - mcp, mode="legacy", sampling_handler=sampling_handler - ) as client: - await client.call_tool("ask", {"question": "hi"}) - - spans = _spans_named(trace_exporter, "sampling create_message") - assert len(spans) == 1 - span = spans[0] - assert span.attributes is not None - assert span.attributes["mcp.method.name"] == "sampling/createMessage" - assert span.attributes["fastmcp.server.name"] == "sampling-server" - # The sampler never forwards attributes, so on_start legitimately sees - # none — this documents that limitation rather than asserting around it. - assert non_forwarding_recorder.attributes["sampling create_message"] == {} - - async def test_sampling_tool_span_keeps_attributes( - self, - trace_exporter: InMemorySpanExporter, - non_forwarding_recorder: OnStartRecorder, - ): - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - def echo_tool(text: str) -> str: - return text - - call_count = 0 - - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - if call_count == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="echo_tool", - input={"text": "hi"}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="done")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def driver(context: Context) -> str: - result = await context.sample(messages="go", tools=[echo_tool]) - return result.text or "" - - async with Client(mcp) as client: - await client.call_tool("driver", {}) - - spans = _spans_named(trace_exporter, "sampling tool echo_tool") - assert len(spans) == 1 - span = spans[0] - assert span.attributes is not None - assert span.attributes["gen_ai.tool.name"] == "echo_tool" - assert span.attributes["fastmcp.tool.use_id"] == "call_1" - # The sampler never forwards attributes, so on_start legitimately sees - # none — this documents that limitation rather than asserting around it. - assert non_forwarding_recorder.attributes["sampling tool echo_tool"] == {} - - -class TestAttributeRestoreRespectsSampler: - """Restoring missing attributes must not clobber attributes a sampler - deliberately kept and modified, must coexist with attributes a sampler - adds of its own, and must not fire at all when a sampler supplies only - its own attributes and drops FastMCP's entirely.""" - - @pytest.fixture - def redacting_tracer( - self, monkeypatch: pytest.MonkeyPatch, trace_exporter: InMemorySpanExporter - ) -> None: - provider = TracerProvider(sampler=RedactingSampler()) - provider.add_span_processor(SimpleSpanProcessor(trace_exporter)) - tracer = provider.get_tracer("test") - monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer) - - @pytest.fixture - def attribute_adding_tracer( - self, monkeypatch: pytest.MonkeyPatch, trace_exporter: InMemorySpanExporter - ) -> None: - provider = TracerProvider(sampler=AttributeAddingSampler()) - provider.add_span_processor(SimpleSpanProcessor(trace_exporter)) - tracer = provider.get_tracer("test") - monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer) - - @pytest.fixture - def filtering_tracer( - self, monkeypatch: pytest.MonkeyPatch, trace_exporter: InMemorySpanExporter - ) -> None: - provider = TracerProvider(sampler=FilteringSampler()) - provider.add_span_processor(SimpleSpanProcessor(trace_exporter)) - tracer = provider.get_tracer("test") - monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer) - - async def test_create_message_span_keeps_redacted_value( - self, - trace_exporter: InMemorySpanExporter, - redacting_tracer: None, - ): - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> str: - return "sampled-text" - - mcp = FastMCP("sampling-server") - - @mcp.tool - async def ask(question: str, context: Context) -> str: - result = await context.sample(messages=question) - return result.text or "" - - async with Client( - mcp, mode="legacy", sampling_handler=sampling_handler - ) as client: - await client.call_tool("ask", {"question": "hi"}) - - spans = _spans_named(trace_exporter, "sampling create_message") - assert len(spans) == 1 - span = spans[0] - assert span.attributes is not None - # The redaction survives — it must not be overwritten by a restore. - assert span.attributes["mcp.method.name"] == "REDACTED" - # Attributes the sampler didn't touch are still present. - assert span.attributes["fastmcp.server.name"] == "sampling-server" - - async def test_create_message_span_keeps_sampler_added_attribute( - self, - trace_exporter: InMemorySpanExporter, - attribute_adding_tracer: None, - ): - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> str: - return "sampled-text" - - mcp = FastMCP("sampling-server") - - @mcp.tool - async def ask(question: str, context: Context) -> str: - result = await context.sample(messages=question) - return result.text or "" - - async with Client( - mcp, mode="legacy", sampling_handler=sampling_handler - ) as client: - await client.call_tool("ask", {"question": "hi"}) - - spans = _spans_named(trace_exporter, "sampling create_message") - assert len(spans) == 1 - span = spans[0] - assert span.attributes is not None - assert span.attributes["sampling.policy"] == "always_on" - assert span.attributes["mcp.method.name"] == "sampling/createMessage" - assert span.attributes["fastmcp.server.name"] == "sampling-server" - - async def test_tool_span_keeps_redacted_value( - self, - trace_exporter: InMemorySpanExporter, - redacting_tracer: None, - ): - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - def echo_tool(text: str) -> str: - return text - - call_count = 0 - - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - if call_count == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="echo_tool", - input={"text": "hi"}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="done")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def driver(context: Context) -> str: - result = await context.sample(messages="go", tools=[echo_tool]) - return result.text or "" - - async with Client(mcp) as client: - await client.call_tool("driver", {}) - - spans = _spans_named(trace_exporter, "sampling tool echo_tool") - assert len(spans) == 1 - span = spans[0] - assert span.attributes is not None - # RedactingSampler only touches mcp.method.name, which this span - # doesn't set — its attributes are forwarded unchanged, confirming - # the restore step doesn't disturb them either. - assert span.attributes["gen_ai.tool.name"] == "echo_tool" - assert span.attributes["fastmcp.tool.use_id"] == "call_1" - - async def test_tool_span_keeps_sampler_added_attribute( - self, - trace_exporter: InMemorySpanExporter, - attribute_adding_tracer: None, - ): - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - def echo_tool(text: str) -> str: - return text - - call_count = 0 - - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - if call_count == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="echo_tool", - input={"text": "hi"}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="done")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def driver(context: Context) -> str: - result = await context.sample(messages="go", tools=[echo_tool]) - return result.text or "" - - async with Client(mcp) as client: - await client.call_tool("driver", {}) - - spans = _spans_named(trace_exporter, "sampling tool echo_tool") - assert len(spans) == 1 - span = spans[0] - assert span.attributes is not None - assert span.attributes["sampling.policy"] == "always_on" - assert span.attributes["gen_ai.tool.name"] == "echo_tool" - assert span.attributes["fastmcp.tool.use_id"] == "call_1" - - async def test_create_message_span_filtered_attributes_are_not_restored( - self, - trace_exporter: InMemorySpanExporter, - filtering_tracer: None, - ): - """Regression: a sampler that intentionally supplies only its own - attributes must not have FastMCP's attributes restored on top.""" - - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> str: - return "sampled-text" - - mcp = FastMCP("sampling-server") - - @mcp.tool - async def ask(question: str, context: Context) -> str: - result = await context.sample(messages=question) - return result.text or "" - - async with Client( - mcp, mode="legacy", sampling_handler=sampling_handler - ) as client: - await client.call_tool("ask", {"question": "hi"}) - - spans = _spans_named(trace_exporter, "sampling create_message") - assert len(spans) == 1 - span = spans[0] - assert dict(span.attributes or {}) == {"sampling.policy": "filtered"} - - async def test_tool_span_filtered_attributes_are_not_restored( - self, - trace_exporter: InMemorySpanExporter, - filtering_tracer: None, - ): - """Regression: a sampler that intentionally supplies only its own - attributes must not have FastMCP's attributes restored on top.""" - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - def echo_tool(text: str) -> str: - return text - - call_count = 0 - - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - if call_count == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="echo_tool", - input={"text": "hi"}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="done")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def driver(context: Context) -> str: - result = await context.sample(messages="go", tools=[echo_tool]) - return result.text or "" - - async with Client(mcp) as client: - await client.call_tool("driver", {}) - - spans = _spans_named(trace_exporter, "sampling tool echo_tool") - assert len(spans) == 1 - span = spans[0] - assert dict(span.attributes or {}) == {"sampling.policy": "filtered"} - - -class TestRestoreDoesNotChurnAttributeLimitEvictions: - """Regression: under a low `OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT`, the restore - step in `fastmcp.server.sampling.run` must not reinsert an attribute the - SDK's bounded attribute map already evicted. - - See `tests/telemetry/test_span_attributes.py:: - test_restore_does_not_churn_sdk_attribute_limit_evictions` for the full - explanation: an evicted key looks identical to a sampler-omitted one from - inside the restore helper, so reinserting it churns which attributes the - SDK ultimately retains and inflates `dropped_attributes` beyond what the - limit alone already cost. This proves the same discriminator holds - end-to-end through the real sampling call path, not just at the helper - level. - """ - - @staticmethod - async def _run_create_message( - span_limits: SpanLimits, *, stub_restore: bool - ) -> ReadableSpan: - with pytest.MonkeyPatch.context() as mp: - exporter = InMemorySpanExporter() - provider = TracerProvider(span_limits=span_limits) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - tracer = provider.get_tracer("test") - mp.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer) - if stub_restore: - mp.setattr( - "fastmcp.server.sampling.run.restore_dropped_attributes", - lambda span, attrs: None, - ) - - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> str: - return "sampled-text" - - mcp = FastMCP("sampling-server") - - @mcp.tool - async def ask(question: str, context: Context) -> str: - result = await context.sample(messages=question) - return result.text or "" - - async with Client( - mcp, mode="legacy", sampling_handler=sampling_handler - ) as client: - await client.call_tool("ask", {"question": "hi"}) - - spans = _spans_named(exporter, "sampling create_message") - assert len(spans) == 1 - return spans[0] - - @staticmethod - async def _run_tool_span( - span_limits: SpanLimits, *, stub_restore: bool - ) -> ReadableSpan: - from mcp_types import CreateMessageResultWithTools, ToolUseContent - - with pytest.MonkeyPatch.context() as mp: - exporter = InMemorySpanExporter() - provider = TracerProvider(span_limits=span_limits) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - tracer = provider.get_tracer("test") - mp.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer) - if stub_restore: - mp.setattr( - "fastmcp.server.sampling.run.restore_dropped_attributes", - lambda span, attrs: None, - ) - - def echo_tool(text: str) -> str: - return text - - call_count = 0 - - def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - ctx: RequestContext, - ) -> CreateMessageResultWithTools: - nonlocal call_count - call_count += 1 - if call_count == 1: - return CreateMessageResultWithTools( - role="assistant", - content=[ - ToolUseContent( - type="tool_use", - id="call_1", - name="echo_tool", - input={"text": "hi"}, - ) - ], - model="test-model", - stop_reason="toolUse", - ) - return CreateMessageResultWithTools( - role="assistant", - content=[TextContent(type="text", text="done")], - model="test-model", - stop_reason="endTurn", - ) - - mcp = FastMCP(sampling_handler=sampling_handler) - - @mcp.tool - async def driver(context: Context) -> str: - result = await context.sample(messages="go", tools=[echo_tool]) - return result.text or "" - - async with Client(mcp) as client: - await client.call_tool("driver", {}) - - spans = _spans_named(exporter, "sampling tool echo_tool") - assert len(spans) == 1 - return spans[0] - - async def test_create_message_span(self, monkeypatch: pytest.MonkeyPatch): - # Two attributes on this span (`mcp.method.name`, `fastmcp.server.name`) - # — a limit of 1 guarantees eviction. - monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "1") - span_limits = SpanLimits() - assert span_limits.max_span_attributes == 1 - - baseline = await self._run_create_message(span_limits, stub_restore=True) - # The whole test is moot if the limit didn't actually bind. - assert baseline.dropped_attributes > 0 - - with_restore = await self._run_create_message(span_limits, stub_restore=False) - - assert dict(with_restore.attributes or {}) == dict(baseline.attributes or {}) - assert with_restore.dropped_attributes == baseline.dropped_attributes - - async def test_tool_span(self, monkeypatch: pytest.MonkeyPatch): - # Two attributes on this span (`gen_ai.tool.name`, `fastmcp.tool.use_id`) - # — a limit of 1 guarantees eviction. - monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "1") - span_limits = SpanLimits() - assert span_limits.max_span_attributes == 1 - - baseline = await self._run_tool_span(span_limits, stub_restore=True) - # The whole test is moot if the limit didn't actually bind. - assert baseline.dropped_attributes > 0 - - with_restore = await self._run_tool_span(span_limits, stub_restore=False) - - assert dict(with_restore.attributes or {}) == dict(baseline.attributes or {}) - assert with_restore.dropped_attributes == baseline.dropped_attributes diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py index 3454d93ab..252d92b70 100644 --- a/tests/server/telemetry/test_server_tracing.py +++ b/tests/server/telemetry/test_server_tracing.py @@ -587,14 +587,14 @@ class TestTelemetryEnabledByDefault: """Instrumentation is on by default and controllable via the off-switch. FastMCP uses only the OpenTelemetry API, so spans are created unconditionally - and light up when an SDK is configured. `FASTMCP_ENABLE_TELEMETRY=false` - (`fastmcp.settings.enable_telemetry`) turns span creation off entirely, so no + and light up when an SDK is configured. `FASTMCP_TELEMETRY_MODE=off` + (`fastmcp.settings.telemetry_mode`) turns span creation off entirely, so no FastMCP spans are exported even with an SDK configured. """ async def test_spans_fire_by_default(self, trace_exporter: InMemorySpanExporter): """No opt-in required: a tool call produces a span out of the box.""" - assert fastmcp.settings.enable_telemetry is True + assert fastmcp.settings.telemetry_mode == "native" mcp = FastMCP("test-server") @@ -615,7 +615,7 @@ class TestTelemetryEnabledByDefault: ): """With telemetry disabled, no spans are created even with an SDK configured (the exporter fixture installs one).""" - monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False) + monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off") mcp = FastMCP("test-server") @@ -641,7 +641,7 @@ class TestTelemetryEnabledByDefault: are governed by the user's OpenTelemetry SDK, not FastMCP's off-switch, so they may still appear — the assertion filters them out. """ - monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False) + monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off") mcp = FastMCP("test-server") @@ -676,7 +676,7 @@ class TestTelemetryEnabledByDefault: `trace.get_current_span()` inside a handler must still return the caller's enclosing span, and attributes written there must land on it. """ - monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False) + monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off") tracer = trace.get_tracer("test-enclosing") captured: dict[str, Span] = {} diff --git a/tests/server/test_context.py b/tests/server/test_context.py index 384a307ac..26b57d694 100644 --- a/tests/server/test_context.py +++ b/tests/server/test_context.py @@ -1,14 +1,12 @@ from unittest.mock import MagicMock import pytest -from mcp_types import ModelPreferences from fastmcp.server.context import ( Context, reset_transport, set_transport, ) -from fastmcp.server.sampling.run import _parse_model_preferences from fastmcp.server.server import FastMCP @@ -17,28 +15,6 @@ def context(): return Context(fastmcp=FastMCP()) -class TestParseModelPreferences: - def test_parse_model_preferences_string(self, context): - mp = _parse_model_preferences("claude-haiku-4-5") - assert isinstance(mp, ModelPreferences) - assert mp.hints is not None - assert mp.hints[0].name == "claude-haiku-4-5" - - def test_parse_model_preferences_list(self, context): - mp = _parse_model_preferences(["claude-haiku-4-5", "claude"]) - assert isinstance(mp, ModelPreferences) - assert mp.hints is not None - assert [h.name for h in mp.hints] == ["claude-haiku-4-5", "claude"] - - def test_parse_model_preferences_object(self, context): - obj = ModelPreferences(hints=[]) - assert _parse_model_preferences(obj) is obj - - def test_parse_model_preferences_invalid_type(self, context): - with pytest.raises(ValueError): - _parse_model_preferences(model_preferences=123) # pyright: ignore[reportArgumentType] # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type] - - class TestSessionId: def test_session_id_with_http_headers(self, context): """Test that session_id returns the value from mcp-session-id header.""" diff --git a/tests/server/test_mrtr_guards_components.py b/tests/server/test_mrtr_guards_components.py new file mode 100644 index 000000000..ad896aa87 --- /dev/null +++ b/tests/server/test_mrtr_guards_components.py @@ -0,0 +1,230 @@ +"""Guard-mode multi-round-trip for prompts and resources (SEP-2322). + +`InputRequiredResult` is a *result type*, not a `tools/call` feature: any +request can resolve to one. A prompt or resource asks for client input exactly +the way a tool does — return the ask, read `ctx.input_responses` on the round +that follows. + +These tests cover the emission side for prompts, concrete resources, and +resource templates, the 2026-07-28 era gate, and the proxy path, where the ask +must be forwarded to the parent rather than answered inside the proxy (a proxy +has no back-channel to the real user). Tool guards live in +``tests/server/test_mrtr_guards.py``. +""" + +from __future__ import annotations + +import mcp_types +import pytest +from mcp.shared.exceptions import MCPError +from mcp_types import ElicitRequest, InputRequiredResult + +from fastmcp import Client, Context, FastMCP +from fastmcp.client.elicitation import ElicitResult + + +def _elicit(key: str, message: str, field: str) -> ElicitRequest: + """A single-field form elicitation request keyed by ``key``.""" + params = mcp_types.ElicitRequestFormParams( + message=message, + requested_schema={ + "type": "object", + "properties": {field: {"type": "string"}}, + "required": [field], + }, + ) + return ElicitRequest(method="elicitation/create", params=params) + + +def _ask( + request: ElicitRequest, key: str, request_state: str | None +) -> InputRequiredResult: + return InputRequiredResult( + result_type="input_required", + input_requests={key: request}, + request_state=request_state, + ) + + +def _accepted(responses: mcp_types.InputResponses, key: str) -> dict[str, object]: + """The accepted form content for one answered elicitation.""" + answer = responses[key] + assert isinstance(answer, mcp_types.ElicitResult) + assert answer.content is not None + return dict(answer.content) + + +def _modern_proxy(backend: FastMCP) -> FastMCP: + """A proxy whose backend client negotiates the modern era, so the backend + can emit an `InputRequiredResult` for the proxy to round-trip.""" + from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient + + return FastMCPProxy(client_factory=lambda: ProxyClient(backend, mode="auto")) + + +class TestPromptGuard: + """`InputRequiredResult` is a result type, not a tools/call feature, so a + prompt can ask for input the same way a tool does (SEP-2322).""" + + @staticmethod + def _context_prompt_server() -> FastMCP: + mcp = FastMCP("prompt-guard") + + @mcp.prompt + async def summarize(ctx: Context) -> str | InputRequiredResult: + responses = ctx.input_responses + if responses is None: + return _ask( + _elicit("context", "What context?", "context"), + key="context", + request_state=None, + ) + return f"Summarizing with {_accepted(responses, 'context')['context']}" + + return mcp + + async def test_prompt_emits_input_required(self): + """The asking round reaches the wire as an InputRequiredResult.""" + async with Client(self._context_prompt_server(), mode="auto") as client: + result = await client.session.get_prompt( + "summarize", allow_input_required=True + ) + + assert isinstance(result, InputRequiredResult) + assert "context" in result.input_requests + + async def test_prompt_completes_with_responses(self): + """Answering the ask renders the prompt on the next round.""" + mcp = self._context_prompt_server() + async with Client(mcp, mode="auto") as client: + ask = await client.session.get_prompt( + "summarize", allow_input_required=True + ) + assert isinstance(ask, InputRequiredResult) + done = await client.session.get_prompt( + "summarize", + input_responses={ + "context": mcp_types.ElicitResult( + action="accept", content={"context": "quarterly report"} + ) + }, + ) + + assert done.messages[0].content.text == ("Summarizing with quarterly report") + + async def test_prompt_guard_rejected_on_handshake_era(self): + """The result type only exists at 2026-07-28, so an older connection + gets the era named rather than a generic invalid-result failure.""" + async with Client(self._context_prompt_server(), mode="legacy") as client: + with pytest.raises(MCPError, match="2026-07-28"): + await client.session.get_prompt("summarize") + + +class TestResourceGuard: + """Resources and templates ask for input the same way tools and prompts do.""" + + @staticmethod + def _resource_server() -> FastMCP: + mcp = FastMCP("resource-guard") + + @mcp.resource("data://report") + async def report(ctx: Context) -> str | InputRequiredResult: + responses = ctx.input_responses + if responses is None: + return _ask( + _elicit("context", "Which quarter?", "context"), + key="context", + request_state=None, + ) + return f"Report for {_accepted(responses, 'context')['context']}" + + @mcp.resource("data://report/{section}") + async def section_report( + section: str, ctx: Context + ) -> str | InputRequiredResult: + responses = ctx.input_responses + if responses is None: + return _ask( + _elicit("context", f"Which quarter for {section}?", "context"), + key="context", + request_state=None, + ) + quarter = _accepted(responses, "context")["context"] + return f"{section} for {quarter}" + + return mcp + + async def test_resource_emits_input_required(self): + async with Client(self._resource_server(), mode="auto") as client: + result = await client.session.read_resource( + "data://report", allow_input_required=True + ) + + assert isinstance(result, InputRequiredResult) + assert "context" in result.input_requests + + async def test_resource_completes_with_responses(self): + async with Client(self._resource_server(), mode="auto") as client: + done = await client.session.read_resource( + "data://report", + input_responses={ + "context": mcp_types.ElicitResult( + action="accept", content={"context": "Q3"} + ) + }, + ) + + assert done.contents[0].text == "Report for Q3" + + async def test_resource_template_emits_input_required(self): + """Templates share the converter, so the ask survives there too.""" + async with Client(self._resource_server(), mode="auto") as client: + result = await client.session.read_resource( + "data://report/revenue", allow_input_required=True + ) + + assert isinstance(result, InputRequiredResult) + assert "context" in result.input_requests + + async def test_resource_guard_rejected_on_handshake_era(self): + async with Client(self._resource_server(), mode="legacy") as client: + with pytest.raises(MCPError, match="2026-07-28"): + await client.session.read_resource("data://report") + + +class TestProxyForwarding: + """A proxy forwards a backend guard's ask instead of answering it.""" + + async def test_guard_prompt_round_trips_through_proxy(self): + """A guard prompt behind a proxy surfaces its ask instead of the proxy + trying to answer it. The proxy has no back-channel to the real user, so + driving the ask internally fails with "Elicitation not supported".""" + backend = TestPromptGuard._context_prompt_server() + + async def answer(message, response_type, params, ctx): + return ElicitResult( + action="accept", content=response_type(context="quarterly report") + ) + + async with Client( + _modern_proxy(backend), mode="auto", elicitation_handler=answer + ) as client: + result = await client.get_prompt("summarize") + + assert result.messages[0].content.text == "Summarizing with quarterly report" + + async def test_guard_resource_round_trips_through_proxy(self): + """Concrete resources and templates forward the ask the same way.""" + backend = TestResourceGuard._resource_server() + + async def answer(message, response_type, params, ctx): + return ElicitResult(action="accept", content=response_type(context="Q3")) + + async with Client( + _modern_proxy(backend), mode="auto", elicitation_handler=answer + ) as client: + direct = await client.read_resource("data://report") + templated = await client.read_resource("data://report/revenue") + + assert direct[0].text == "Report for Q3" + assert templated[0].text == "revenue for Q3" diff --git a/tests/server/test_protocol_eras.py b/tests/server/test_protocol_eras.py index 972ba1733..2172b7156 100644 --- a/tests/server/test_protocol_eras.py +++ b/tests/server/test_protocol_eras.py @@ -30,7 +30,7 @@ from mcp.shared.exceptions import MCPError from pydantic import FileUrl from fastmcp import Client as FastMCPClient -from fastmcp import Context, FastMCP, settings +from fastmcp import Context, FastMCP from fastmcp.exceptions import PromptError, ResourceError from fastmcp.server.elicitation import AcceptedElicitation from fastmcp.server.middleware import Middleware @@ -172,6 +172,7 @@ async def test_legacy_uses_initialize_handshake(dual_era_server): """ async with SDKClient(_server(dual_era_server), mode="legacy") as client: assert client.protocol_version == "2025-11-25" + assert client.server_info is not None assert client.server_info.name == "dual-era" @@ -182,14 +183,16 @@ async def test_auto_negotiates_modern_via_discover(dual_era_server): async with SDKClient(_server(dual_era_server), mode="auto") as client: assert client.protocol_version == "2026-07-28" # server/discover carries identity, unlike the synthesized pin below. + assert client.server_info is not None assert client.server_info.name == "dual-era" assert client.server_capabilities is not None async def test_pinned_modern_adopts_without_probe(dual_era_server): """Pinning `mode='2026-07-28'` adopts the version directly. With no - `prior_discover`, the SDK synthesizes a minimal DiscoverResult, so - server_info is empty even though the protocol version is modern. + `prior_discover`, the SDK synthesizes a minimal DiscoverResult that carries + no identity, so server_info is absent even though the protocol version is + modern. Characterization of the SDK's synthesize-discover path (mcp.client.client `_synthesize_discover`): a pin without prior_discover trades identity for @@ -197,7 +200,7 @@ async def test_pinned_modern_adopts_without_probe(dual_era_server): """ async with SDKClient(_server(dual_era_server), mode="2026-07-28") as client: assert client.protocol_version == "2026-07-28" - assert client.server_info.name == "" + assert client.server_info is None # --------------------------------------------------------------------------- @@ -215,16 +218,6 @@ def push_server() -> FastMCP: assert isinstance(result, AcceptedElicitation) return f"elicited {result.data}" - @mcp.tool - async def do_sample(ctx: Context) -> str: - result = await ctx.sample("hello") - return f"sampled {result.text}" - - @mcp.tool - async def do_list_roots(ctx: Context) -> str: - roots = await ctx.list_roots() - return f"roots {[str(r.uri) for r in roots]}" - @mcp.tool async def do_log(ctx: Context) -> str: await ctx.info("a log line") @@ -264,31 +257,12 @@ async def test_elicit_works_on_legacy(push_server): assert _texts(result.content) == ["elicited 7"] -async def test_sample_works_on_legacy(push_server): - async with SDKClient( - _server(push_server), mode="legacy", sampling_callback=_sampling_cb - ) as client: - result = await client.call_tool("do_sample", {}) - assert result.is_error is False - assert _texts(result.content) == ["sampled sampled-text"] - - -async def test_list_roots_works_on_legacy(push_server): - async with SDKClient( - _server(push_server), mode="legacy", list_roots_callback=_roots_cb - ) as client: - result = await client.call_tool("do_list_roots", {}) - assert result.is_error is False - assert _texts(result.content) == ["roots ['file:///tmp']"] - - @pytest.mark.parametrize("mode", MODERN_MODES) -@pytest.mark.parametrize("tool", ["do_elicit", "do_sample", "do_list_roots"]) -async def test_push_features_degrade_on_modern(push_server, mode, tool): - """Server-initiated requests (elicitation/sampling/roots) are removed at - 2026-07-28 (SEP-2577), so a tool that uses them must degrade to a surfaced - error rather than hang or crash the connection. This asserts the - degradation happens and reaches the caller as an isError result. +async def test_elicit_degrades_on_modern(push_server, mode): + """Elicitation is a server-initiated request, removed at 2026-07-28 + (SEP-2577), so a tool that uses it must degrade to a surfaced error rather + than hang or crash the connection. The connection survives: a subsequent + normal call still works. """ async with SDKClient( _server(push_server), @@ -297,230 +271,46 @@ async def test_push_features_degrade_on_modern(push_server, mode, tool): sampling_callback=_sampling_cb, list_roots_callback=_roots_cb, ) as client: - result = await client.call_tool(tool, {}) + result = await client.call_tool("do_elicit", {}) assert result.is_error is True - # A subsequent normal call still works: the connection survived the - # per-request failure rather than tearing down the whole session. log_result = await client.call_tool("do_log", {}) assert log_result.is_error is False -async def test_list_roots_degradation_message_is_clear_on_modern(push_server): - """`ctx.list_roots()` sends with no related_request_id, so the SDK selects - the connection's no-back-channel outbound and raises the self-explanatory - NoBackChannelError. This is the *good* degradation message and we assert it. - """ - async with SDKClient(_server(push_server), mode="2026-07-28") as client: - result = await client.call_tool("do_list_roots", {}) - assert result.is_error is True - message = " ".join(_texts(result.content)).lower() - assert "back-channel" in message and "server-initiated" in message - - -@pytest.mark.parametrize("tool", ["do_elicit", "do_sample"]) -async def test_elicit_sample_degradation_message_is_clear_on_modern(push_server, tool): - """FastMCP era-gates elicit/sample: on a 2026-07-28 connection they raise a - clear, era-aware error before hitting the wire, instead of the SDK's opaque - 'Method not found' (sdk-feedback.md #10). Both messages name the removed - server-initiated capability so the caller knows why the request degraded. +async def test_elicit_degradation_message_is_clear_on_modern(push_server): + """FastMCP era-gates elicit: on a 2026-07-28 connection it raises a clear, + era-aware error before hitting the wire, instead of the SDK's opaque + 'Method not found' (sdk-feedback.md #10). """ async with SDKClient( _server(push_server), mode="2026-07-28", elicitation_callback=_accept_elicit, - sampling_callback=_sampling_cb, ) as client: - result = await client.call_tool(tool, {}) - assert result.is_error is True - message = " ".join(_texts(result.content)).lower() - assert "server-initiated" in message - - -# --------------------------------------------------------------------------- -# 3a-bis. Server-configured sampling handler answers WITHOUT the client -# back-channel, so ctx.sample()/ctx.sample_step() must keep working on modern -# connections. The era-gate only fires when nothing can serve the request. -# --------------------------------------------------------------------------- - - -def _handler_server(behavior) -> FastMCP: - """A server whose sampling is answered by a server-side handler.""" - - def sampling_handler(messages, params, ctx) -> str: - return "handler-answer" - - mcp = FastMCP("handler", sampling_handler=sampling_handler) - if behavior is not None: - mcp.sampling_handler_behavior = behavior - - @mcp.tool - async def do_sample(ctx: Context) -> str: - result = await ctx.sample("hello") - return f"sampled {result.text}" - - @mcp.tool - async def do_sample_step(ctx: Context) -> str: - step = await ctx.sample_step("hello") - return f"stepped {step.text}" - - return mcp - - -@pytest.mark.parametrize("mode", MODERN_MODES) -@pytest.mark.parametrize("behavior", ["always", "fallback"]) -@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"]) -async def test_server_sampling_handler_works_on_modern(mode, behavior, method): - """A server-side sampling handler answers entirely server-side, so it works - on modern (2026-07-28) connections regardless of behavior. The era-gate must - NOT block these — nothing touches the removed client back-channel. Crucially, - 'fallback' must go straight to the handler (no bare client-attempt failure).""" - server = _handler_server(behavior) - async with SDKClient(_server(server), mode=mode) as client: - result = await client.call_tool(method, {}) - assert result.is_error is False - assert "handler-answer" in " ".join(_texts(result.content)) - - -@pytest.mark.parametrize("behavior", ["always", "fallback"]) -@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"]) -async def test_server_sampling_handler_works_on_legacy(behavior, method): - """Handshake-era behavior is unchanged: the server-side handler still answers - on legacy connections.""" - server = _handler_server(behavior) - async with SDKClient(_server(server), mode="legacy") as client: - result = await client.call_tool(method, {}) - assert result.is_error is False - assert "handler-answer" in " ".join(_texts(result.content)) - - -@pytest.mark.parametrize("mode", MODERN_MODES) -@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"]) -async def test_sampling_without_handler_still_era_gated_on_modern( - push_server, mode, method -): - """With no server-side handler configured, the request would hit the removed - client back-channel, so the clear era error still fires on modern.""" - # push_server only defines do_sample; add a do_sample_step twin inline. - mcp = FastMCP("no-handler") - - @mcp.tool - async def do_sample(ctx: Context) -> str: - result = await ctx.sample("hello") - return f"sampled {result.text}" - - @mcp.tool - async def do_sample_step(ctx: Context) -> str: - step = await ctx.sample_step("hello") - return f"stepped {step.text}" - - async with SDKClient( - _server(mcp), mode=mode, sampling_callback=_sampling_cb - ) as client: - result = await client.call_tool(method, {}) + result = await client.call_tool("do_elicit", {}) assert result.is_error is True assert "server-initiated" in " ".join(_texts(result.content)).lower() # --------------------------------------------------------------------------- -# 3b. Sampling deprecation warning (SEP-2577): ctx.sample/ctx.sample_step warn +# 3a-bis. Sampling and roots are not in the server API at all # --------------------------------------------------------------------------- -@pytest.fixture -def reset_sample_warn_flag(): - """Reset the process-wide warn-once flag so a warning can be observed.""" - import fastmcp.server.context as context_module - - original = set(context_module._sample_deprecation_warned) - context_module._sample_deprecation_warned.clear() - try: - yield - finally: - context_module._sample_deprecation_warned.clear() - context_module._sample_deprecation_warned.update(original) +@pytest.mark.parametrize("name", ["sample", "sample_step", "list_roots"]) +def test_removed_server_initiated_methods_are_absent(name): + """FastMCP 4 targets the modern protocol, so the capabilities SEP-2577 + removed are not in the server-authoring API — not deprecated, not era-gated, + absent. A server that calls them fails at attribute lookup, in every era. + """ + assert not hasattr(Context, name) -@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"]) -async def test_sampling_emits_deprecation_warning(reset_sample_warn_flag, method): - """`ctx.sample()` and `ctx.sample_step()` emit a FastMCPDeprecationWarning - naming SEP-2577 and the server-side-LLM migration path.""" - from fastmcp.exceptions import FastMCPDeprecationWarning - - mcp = FastMCP("warn") - - @mcp.tool - async def do_sample(ctx: Context) -> str: - await ctx.sample("hello") - return "ok" - - @mcp.tool - async def do_sample_step(ctx: Context) -> str: - await ctx.sample_step("hello") - return "ok" - - with pytest.warns(FastMCPDeprecationWarning, match="SEP-2577"): - async with SDKClient( - _server(mcp), mode="legacy", sampling_callback=_sampling_cb - ) as client: - await client.call_tool(method, {}) - - -async def test_sampling_deprecation_warning_fires_once_per_process( - reset_sample_warn_flag, -): - """The deprecation warning is warn-once: a second sample call in the same - process does not re-warn.""" - from fastmcp.exceptions import FastMCPDeprecationWarning - - mcp = FastMCP("warn-once") - - @mcp.tool - async def do_sample(ctx: Context) -> str: - await ctx.sample("hello") - return "ok" - - with pytest.warns(FastMCPDeprecationWarning): - async with SDKClient( - _server(mcp), mode="legacy", sampling_callback=_sampling_cb - ) as client: - await client.call_tool("do_sample", {}) - - import warnings as _warnings - - with _warnings.catch_warnings(): - _warnings.simplefilter("error", FastMCPDeprecationWarning) - async with SDKClient( - _server(mcp), mode="legacy", sampling_callback=_sampling_cb - ) as client: - result = await client.call_tool("do_sample", {}) - assert result.is_error is False - - -async def test_sampling_deprecation_warning_suppressible_via_settings( - reset_sample_warn_flag, monkeypatch -): - """Setting `deprecation_warnings=False` suppresses the sampling warning, - matching the house pattern for every other FastMCP deprecation.""" - import warnings as _warnings - - from fastmcp.exceptions import FastMCPDeprecationWarning - - monkeypatch.setattr(settings, "deprecation_warnings", False) - - mcp = FastMCP("no-warn") - - @mcp.tool - async def do_sample(ctx: Context) -> str: - await ctx.sample("hello") - return "ok" - - with _warnings.catch_warnings(): - _warnings.simplefilter("error", FastMCPDeprecationWarning) - async with SDKClient( - _server(mcp), mode="legacy", sampling_callback=_sampling_cb - ) as client: - result = await client.call_tool("do_sample", {}) - assert result.is_error is False +@pytest.mark.parametrize("kwarg", ["sampling_handler", "sampling_handler_behavior"]) +def test_server_sampling_handler_kwargs_are_rejected(kwarg): + """The server-side sampling handler existed only to answer `ctx.sample()`.""" + with pytest.raises(TypeError, match="SEP-2577"): + FastMCP("gone", **{kwarg: None}) # ty: ignore[invalid-argument-type] @pytest.mark.parametrize("mode", MODERN_MODES) @@ -566,23 +356,17 @@ async def test_session_id_access_does_not_crash_on_modern(sessionless_server, mo @pytest.mark.parametrize("mode", MODERN_MODES) -async def test_set_logging_level_does_not_crash_on_modern(sessionless_server, mode): - """logging/setLevel is a session-id-keyed, deprecated-at-2026 operation. - On a sessionless modern in-memory connection it must degrade cleanly (either - succeed as a no-op or raise a surfaced MCPError) rather than crash the - connection. Characterization: capture whichever the current contract is. +async def test_set_logging_level_is_era_gated_on_modern(sessionless_server, mode): + """`logging/setLevel` asks a server to remember a level for the session, and + the modern era has no session — the method is absent from its registry. The + FastMCP client says so plainly instead of no-opping or surfacing the SDK's + opaque "Method not found", and the connection stays usable afterward. """ - async with SDKClient(_server(sessionless_server), mode=mode) as client: - outcome: str - try: - await client.set_logging_level("debug") # ty: ignore[deprecated] - outcome = "ok" - except MCPError: - outcome = "mcperror" - # Either way the connection is still usable afterward. + async with FastMCPClient(sessionless_server, mode=mode) as client: + with pytest.raises(RuntimeError, match="2026-07-28"): + await client.set_logging_level("debug") result = await client.call_tool("read_session_id", {}) assert result.is_error is False - assert outcome in {"ok", "mcperror"} # --------------------------------------------------------------------------- diff --git a/tests/server/transforms/test_model_visibility_boundary.py b/tests/server/transforms/test_model_visibility_boundary.py new file mode 100644 index 000000000..f1d98b67d --- /dev/null +++ b/tests/server/transforms/test_model_visibility_boundary.py @@ -0,0 +1,168 @@ +"""App-only tools must not reach the model through server-driven surfaces. + +`tools/list` carries app-only tools on purpose — intermediaries need them to +forward, and the MCP Apps spec puts visibility filtering on the host. That +division holds only where a host sits between the server and the model. + +A search result, a code-mode catalog, and a call-tool proxy are all driven by +the server itself: the first two reach the model as ordinary tool output, and +the third invokes on a name the model supplies. No host mediates any of them, +so the visibility declaration has to be applied server-side. +""" + +from __future__ import annotations + +import json + +import pytest + +from fastmcp import Client, FastMCP, FastMCPApp +from fastmcp.exceptions import ToolError +from fastmcp.experimental.transforms.code_mode import CodeMode +from fastmcp.server.providers.addressing import hashed_backend_name +from fastmcp.server.transforms.search import BM25SearchTransform, RegexSearchTransform +from fastmcp.tools.base import Tool + + +def build_server_without_transform() -> FastMCP: + return _build(None) + + +def build_server(transform) -> FastMCP: + return _build(transform) + + +def _build(transform) -> FastMCP: + app = FastMCPApp("contacts") + + @app.tool() + def save_contact(name: str) -> str: + """UI-only backend that writes a contact.""" + return f"saved {name}" + + @app.tool(model=True) + def search_contacts(query: str) -> str: + """Model-visible backend.""" + return f"found {query}" + + @app.ui() + def contacts_ui() -> str: + return "ui" + + server = FastMCP("Platform") + server.add_provider(app) + if transform is not None: + server.add_transform(transform) + return server + + +CATALOG_TRANSFORMS = [ + pytest.param(RegexSearchTransform, id="regex-search"), + pytest.param(BM25SearchTransform, id="bm25-search"), + pytest.param(CodeMode, id="code-mode"), +] + + +@pytest.mark.parametrize("transform_cls", CATALOG_TRANSFORMS) +async def test_app_only_tools_stay_out_of_model_catalogs(transform_cls): + """Discovery surfaces hand tool definitions straight to the model.""" + server = build_server(transform_cls()) + + async with Client(server) as client: + blob = "" + for tool in await client.list_tools(): + if "search" not in tool.name: + continue + # Each transform names its search argument differently; the + # schema is the authority. + (argument,) = (tool.input_schema or {}).get("required", ["query"]) + result = await client.call_tool(tool.name, {argument: "search_contacts"}) + blob += json.dumps(result.structured_content or "") + blob += "".join( + block.text for block in result.content if hasattr(block, "text") + ) + + assert blob, "no search surface produced output" + assert "save_contact" not in blob + assert "search_contacts" in blob + + +async def test_app_only_tools_are_listed_for_forwarding(): + """The wire listing keeps them: a proxy cannot forward what it cannot see. + + Only the model-facing catalog is filtered, so a server without a catalog + transform still advertises the tool and its declaration for a host to + act on. + """ + plain = build_server_without_transform() + + async with Client(plain) as client: + listed = {tool.name: tool for tool in await client.list_tools()} + + assert "save_contact" in listed + assert listed["save_contact"].meta is not None + assert listed["save_contact"].meta["ui"]["visibility"] == ["app"] + + +async def test_call_tool_proxy_refuses_undiscoverable_tools(): + """The proxy takes a model-supplied name, so it is a second door in.""" + server = build_server(RegexSearchTransform()) + + async with Client(server) as client: + with pytest.raises(ToolError, match="save_contact"): + await client.call_tool( + "call_tool", + {"name": "save_contact", "arguments": {"name": "eve"}}, + ) + + allowed = await client.call_tool( + "call_tool", + {"name": "search_contacts", "arguments": {"query": "ada"}}, + ) + assert allowed.content[0].text == "found ada" # type: ignore[union-attr] + + +@pytest.mark.parametrize("transform_cls", CATALOG_TRANSFORMS) +async def test_the_apps_own_ui_still_reaches_its_backend(transform_cls): + """The point of the boundary is the audience, not the tool: a UI calling + by identity is not the model, and must still work. + """ + server = build_server(transform_cls()) + + result = await server.call_tool( + hashed_backend_name("contacts", "save_contact"), {"name": "ada"} + ) + assert result.content[0].text == "saved ada" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + +async def test_visibility_is_checked_on_the_version_a_name_reaches(): + """A bare name selects the highest version, so that is the one whose + declaration governs. Checking before deduplication would advertise a + model-visible older version whose name runs an app-only newer one. + """ + + def versioned(version: str, visibility: list[str], marker: str) -> Tool: + def same() -> str: + return f"ran {marker}" + + return Tool.from_function( + same, name="same", version=version, meta={"ui": {"visibility": visibility}} + ) + + app = FastMCPApp("contacts") + app.add_tool(versioned("1.0.0", ["app", "model"], "v1")) + app.add_tool(versioned("2.0.0", ["app"], "v2")) + + server = FastMCP("Platform") + server.add_provider(app) + server.add_transform(RegexSearchTransform()) + + async with Client(server) as client: + found = await client.call_tool("search_tools", {"pattern": "same"}) + blob = json.dumps(found.structured_content or "") + "".join( + block.text for block in found.content if hasattr(block, "text") + ) + assert "same" not in blob + + with pytest.raises(ToolError, match="same"): + await client.call_tool("call_tool", {"name": "same", "arguments": {}}) diff --git a/tests/tasks/client/test_client_tool_tasks.py b/tests/tasks/client/test_client_tool_tasks.py index 815adaeba..f809b34d0 100644 --- a/tests/tasks/client/test_client_tool_tasks.py +++ b/tests/tasks/client/test_client_tool_tasks.py @@ -135,9 +135,9 @@ async def test_tool_task_cancel(): assert final.status == "cancelled" -async def test_required_mode_without_optin_raises_32003(): +async def test_required_mode_without_optin_raises_32021(): """A legacy client never negotiates the tasks capability, so a required-mode - tool call is rejected with the -32003 missing-capability error.""" + tool call is rejected with the -32021 missing-capability error.""" mcp = FastMCP("required-test") mcp.add_extension(TasksExtension()) diff --git a/tests/tasks/server/test_extension.py b/tests/tasks/server/test_extension.py index 5260565a5..44dc7ef9c 100644 --- a/tests/tasks/server/test_extension.py +++ b/tests/tasks/server/test_extension.py @@ -1,7 +1,7 @@ """End-to-end tests for the SEP-2663 `TasksExtension` server adapter. Covers the decide-and-task interceptor (forbidden/optional/required modes and the --32003 missing-capability error), the tasks/get|update|cancel handlers, status +-32021 missing-capability error), the tasks/get|update|cancel handlers, status mapping, inlined completed results, argument-coercion parity, TTL, and capability advertisement. Server-side tasks are driven in-process via `task_helpers` because there is no client task-submission API until Phase 4. @@ -360,7 +360,7 @@ async def test_legacy_era_opt_in_is_ignored(): async def test_legacy_era_required_tool_raises_missing_capability(): - """`required` tools refuse legacy-era calls with -32003 even when opted in.""" + """`required` tools refuse legacy-era calls with -32021 even when opted in.""" mcp = _tasks_server() async with running_task_server(mcp): srctx = ServerRequestContext( @@ -377,7 +377,7 @@ async def test_legacy_era_required_tool_raises_missing_capability(): with bind_request_context(srctx): with pytest.raises(MCPError) as exc_info: await mcp.call_tool("must_task", {"n": 3}) - assert exc_info.value.error.code == -32003 + assert exc_info.value.error.code == -32021 # --------------------------------------------------------------------------- @@ -409,12 +409,21 @@ async def test_worker_hooks_survive_sibling_server_shutdown(): # --------------------------------------------------------------------------- -# Compliance: -32003 on task methods for non-declaring clients (SEP-2663) +# Compliance: -32021 on task methods for non-declaring clients (SEP-2663) # --------------------------------------------------------------------------- +def test_missing_capability_code_is_the_protocol_value(): + """The code must track the SDK, not an early SEP-2663 draft. + + It shipped hardcoded as -32003, which no client recognizes: SEP-2575 + assigns -32021 to MissingRequiredClientCapability. + """ + assert MISSING_REQUIRED_CLIENT_CAPABILITY == -32021 + + async def test_task_method_without_capability_raises_missing_capability(): - """tasks/get from a client that did not declare the extension gets -32003.""" + """tasks/get from a client that did not declare the extension gets -32021.""" mcp = _tasks_server() extension = cast(TasksExtension, mcp._extensions[TASKS_EXTENSION_ID]) # A request context with no tasks capability in its _meta. diff --git a/tests/tasks/server/test_guard_reentrant.py b/tests/tasks/server/test_guard_reentrant.py index 5adad9c32..fb4cc52a7 100644 --- a/tests/tasks/server/test_guard_reentrant.py +++ b/tests/tasks/server/test_guard_reentrant.py @@ -11,9 +11,14 @@ the real interceptor and handlers via `task_helpers`. from __future__ import annotations +import asyncio from typing import Any import mcp_types +from fastmcp_tasks.context import get_task_scope +from fastmcp_tasks.input_store import acquire_update_lock, release_update_lock +from mcp.shared.exceptions import MCPError +from mcp_types import INTERNAL_ERROR from fastmcp import Context, FastMCP from fastmcp_tasks import TasksExtension @@ -58,6 +63,14 @@ def _input_required( ) +def _key_asking(input_requests: dict[str, Any], message: str) -> str: + """The surfaced key whose parked request asks *message*.""" + for key, payload in input_requests.items(): + if payload["params"]["message"] == message: + return key + raise AssertionError(f"no parked request asks {message!r}") + + async def _park_key(mcp: FastMCP, task_id: str) -> str: parked = await wait_for_task( mcp, task_id, target_states=frozenset({"input_required"}) @@ -244,3 +257,187 @@ async def test_state_only_guard_round_fails_clearly(): assert final.result is not None assert final.result["isError"] is True assert "state-only" in final.result["content"][0]["text"] + + +async def test_partial_update_keeps_task_parked_on_remaining_request(): + """SEP-2663 partial fulfillment: a leg that asked two questions stays + `input_required` until both are answered, and each `tasks/get` in between + surfaces only what is still outstanding.""" + mcp = FastMCP("partial") + mcp.add_extension(TasksExtension()) + + @mcp.tool(task=True) + async def two_questions(ctx: Context) -> str | mcp_types.InputRequiredResult: + responses = ctx.input_responses + if responses is None: + return _input_required( + { + "first": _elicit_request("First?"), + "second": _elicit_request("Second?"), + } + ) + return f"{_answer(responses, 'first')}+{_answer(responses, 'second')}" + + async with running_task_server(mcp): + created = await submit_task(mcp, "two_questions", {}) + parked = await wait_for_task( + mcp, created.task_id, target_states=frozenset({"input_required"}) + ) + assert parked.input_requests is not None + assert len(parked.input_requests) == 2 + + # Surfaced keys are freshly minted per request, so they carry no order + # a test can rely on. Identify each by the question it asks. + answered = _key_asking(parked.input_requests, "First?") + pending = _key_asking(parked.input_requests, "Second?") + await update_task( + mcp, + created.task_id, + {answered: {"action": "accept", "content": {"value": "one"}}}, + ) + + still_parked = await get_task(mcp, created.task_id) + assert still_parked.status == "input_required" + assert still_parked.input_requests is not None + assert list(still_parked.input_requests) == [pending] + + # Answering the last one resumes the leg, which now sees both answers. + await update_task( + mcp, + created.task_id, + {pending: {"action": "accept", "content": {"value": "two"}}}, + ) + final = await wait_for_task(mcp, created.task_id) + + assert final.status == "completed" + assert final.result is not None + assert final.result["content"][0]["text"] == "one+two" + + +async def test_partial_update_waits_for_a_held_update_lock(): + """An update that arrives while another holds the lock must still land. + + SEP-2663 invites a client to answer a multi-request ask one key at a time, + so two updates can be in flight carrying *different* answers. Acknowledging + the one that loses the lock without storing its answer would leave the task + waiting forever on a key the client believes it already sent. + + The lock is taken out of band here so the contention is deterministic rather + than dependent on scheduling. + """ + mcp = FastMCP("lock-contention") + mcp.add_extension(TasksExtension()) + + @mcp.tool(task=True) + async def two_questions(ctx: Context) -> str | mcp_types.InputRequiredResult: + responses = ctx.input_responses + if responses is None: + return _input_required( + { + "first": _elicit_request("First?"), + "second": _elicit_request("Second?"), + } + ) + return f"{_answer(responses, 'first')}+{_answer(responses, 'second')}" + + async with running_task_server(mcp): + created = await submit_task(mcp, "two_questions", {}) + parked = await wait_for_task( + mcp, created.task_id, target_states=frozenset({"input_required"}) + ) + assert parked.input_requests is not None + first = _key_asking(parked.input_requests, "First?") + second = _key_asking(parked.input_requests, "Second?") + + docket = mcp._docket + assert docket is not None + scope = get_task_scope() + + # Simulate a concurrent update in progress. + assert await acquire_update_lock(docket, scope, created.task_id) + pending = asyncio.create_task( + update_task( + mcp, + created.task_id, + {first: {"action": "accept", "content": {"value": "one"}}}, + ) + ) + await asyncio.sleep(0.05) + assert not pending.done(), "update returned while the lock was held" + await release_update_lock(docket, scope, created.task_id) + await pending + + # The blocked answer landed, so only the other key remains outstanding. + still_parked = await get_task(mcp, created.task_id) + assert still_parked.status == "input_required" + assert still_parked.input_requests is not None + assert list(still_parked.input_requests) == [second] + + await update_task( + mcp, + created.task_id, + {second: {"action": "accept", "content": {"value": "two"}}}, + ) + final = await wait_for_task(mcp, created.task_id) + + assert final.status == "completed" + assert final.result is not None + assert final.result["content"][0]["text"] == "one+two" + + +async def test_final_answer_keeps_task_parked_until_next_leg_is_durable(): + """The last answer must not retire its outstanding marker early. + + Outstanding requests are what make a completed-but-parked leg read as + `input_required`. Discarding the final one before the next leg is enqueued + would let a `tasks/get` landing in that window see a finished execution with + no result and report the task complete. + """ + mcp = FastMCP("durable-reentry") + mcp.add_extension(TasksExtension()) + + @mcp.tool(task=True) + async def one_question(ctx: Context) -> str | mcp_types.InputRequiredResult: + responses = ctx.input_responses + if responses is None: + return _input_required({"only": _elicit_request("Only?")}) + return f"got {_answer(responses, 'only')}" + + async with running_task_server(mcp): + created = await submit_task(mcp, "one_question", {}) + key = await _park_key(mcp, created.task_id) + + await update_task( + mcp, + created.task_id, + {key: {"action": "accept", "content": {"value": "answer"}}}, + ) + final = await wait_for_task(mcp, created.task_id) + + # The task must land on the real result, never on a phantom completion. + assert final.status == "completed" + assert final.result is not None + assert final.result["content"][0]["text"] == "got answer" + + +async def test_protocol_error_fails_the_task_with_inlined_error(): + """SEP-2663 reserves `failed` for protocol faults: an `MCPError` raised by + the body is inlined as a JSON-RPC error rather than reported as a completed + task carrying an `isError` result (which is what a `ToolError` produces).""" + mcp = FastMCP("protocol-fault") + mcp.add_extension(TasksExtension()) + + @mcp.tool(task=True) + async def explodes() -> str: + raise MCPError(code=INTERNAL_ERROR, message="protocol fault", data={"x": 1}) + + async with running_task_server(mcp): + created = await submit_task(mcp, "explodes", {}) + final = await wait_for_task(mcp, created.task_id) + + assert final.status == "failed" + assert final.result is None + assert final.error is not None + assert final.error["code"] == INTERNAL_ERROR + assert final.error["message"] == "protocol fault" + assert final.error["data"] == {"x": 1} diff --git a/tests/tasks/server/test_task_config.py b/tests/tasks/server/test_task_config.py index 42c5e7e98..f839debff 100644 --- a/tests/tasks/server/test_task_config.py +++ b/tests/tasks/server/test_task_config.py @@ -166,7 +166,7 @@ class TestToolModeEnforcement: return mcp async def test_required_mode_without_opt_in_raises(self): - """Required mode raises -32003 when called without a tasks opt-in.""" + """Required mode raises -32021 when called without a tasks opt-in.""" mcp = self._server() async with running_task_server(mcp): with pytest.raises(MCPError) as exc_info: diff --git a/tests/tasks/server/test_task_return_types.py b/tests/tasks/server/test_task_return_types.py index e1a3106c6..eb65fabd9 100644 --- a/tests/tasks/server/test_task_return_types.py +++ b/tests/tasks/server/test_task_return_types.py @@ -50,7 +50,7 @@ def _sync_result_to_wire(result: ToolResult) -> dict[str, Any]: content, structured_content = mcp_result call_tool_result = mcp_types.CallToolResult( content=content, - structuredContent=structured_content, + structured_content=structured_content, ) else: call_tool_result = mcp_types.CallToolResult(content=mcp_result) diff --git a/tests/telemetry/test_interop.py b/tests/telemetry/test_interop.py new file mode 100644 index 000000000..5befb5811 --- /dev/null +++ b/tests/telemetry/test_interop.py @@ -0,0 +1,280 @@ +"""Tests for telemetry interoperability modes. + +Validates that FastMCP's own spans can be suppressed — globally via +`telemetry_mode` or per-block via `suppress_fastmcp_telemetry()` — while trace +context propagation keeps working in `propagation_only` mode and is fully +disabled in `off` mode. +""" + +from __future__ import annotations + +import pytest +from opentelemetry import context as otel_context +from opentelemetry import trace as otel_trace +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import INVALID_SPAN, SpanKind + +import fastmcp +from fastmcp import Client, Context, FastMCP +from fastmcp.client.telemetry import client_span +from fastmcp.server.telemetry import delegate_span, server_span +from fastmcp.telemetry import ( + extract_trace_context, + inject_trace_context, + native_spans_enabled, + suppress_fastmcp_telemetry, + telemetry_mode, +) + +# A well-formed W3C traceparent for extraction tests. +TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + + +@pytest.fixture +def mode(monkeypatch: pytest.MonkeyPatch): + """Set `fastmcp.settings.telemetry_mode` for the duration of a test.""" + + def _set(value: str) -> None: + monkeypatch.setattr(fastmcp.settings, "telemetry_mode", value) + + return _set + + +def fastmcp_spans(exporter: InMemorySpanExporter) -> list[str]: + """Names of spans emitted by FastMCP's own instrumentation scope.""" + return [ + s.name + for s in exporter.get_finished_spans() + if s.instrumentation_scope is not None + and s.instrumentation_scope.name == "fastmcp" + ] + + +class TestTelemetryModeResolution: + def test_native_by_default(self): + assert telemetry_mode() == "native" + assert native_spans_enabled() + + @pytest.mark.parametrize("value", ["propagation_only", "off"]) + def test_setting_disables_native_spans(self, value: str, mode): + mode(value) + assert telemetry_mode() == value + assert not native_spans_enabled() + + def test_suppress_downgrades_native_to_propagation_only(self): + with suppress_fastmcp_telemetry(): + assert telemetry_mode() == "propagation_only" + assert not native_spans_enabled() + assert telemetry_mode() == "native" + + def test_suppress_cannot_override_off(self, mode): + """`off` means FastMCP touches nothing. A narrower request to skip + FastMCP's spans must not re-enable the propagation `off` omits.""" + mode("off") + with suppress_fastmcp_telemetry(): + assert telemetry_mode() == "off" + + def test_suppress_nests(self): + with suppress_fastmcp_telemetry(): + with suppress_fastmcp_telemetry(): + assert not native_spans_enabled() + # Outer suppression still active after the inner block exits. + assert not native_spans_enabled() + assert native_spans_enabled() + + def test_suppress_restores_on_exception(self): + with pytest.raises(RuntimeError): + with suppress_fastmcp_telemetry(): + raise RuntimeError("boom") + assert native_spans_enabled() + + +class TestSpanHelperSuppression: + """Every FastMCP span helper goes quiet when its own spans are disabled.""" + + @pytest.fixture + def helpers(self): + return { + "server": lambda: server_span( + name="test_op", + method="tools/call", + server_name="test-server", + component_type="tool", + component_key="tool://test", + ), + "client": lambda: client_span( + name="test_client", + method="tools/call", + component_key="tool://test", + ), + "delegate": lambda: delegate_span( + name="test_delegate", + provider_type="FastMCPProvider", + component_key="tool://test", + ), + } + + @pytest.mark.parametrize("helper", ["server", "client", "delegate"]) + @pytest.mark.parametrize("value", ["propagation_only", "off"]) + def test_helper_emits_nothing( + self, + helper: str, + value: str, + helpers, + mode, + trace_exporter: InMemorySpanExporter, + ): + mode(value) + with helpers[helper]() as span: + assert span is INVALID_SPAN + assert trace_exporter.get_finished_spans() == () + + @pytest.mark.parametrize("helper", ["server", "client", "delegate"]) + def test_helper_emits_nothing_under_suppress( + self, helper: str, helpers, trace_exporter: InMemorySpanExporter + ): + with suppress_fastmcp_telemetry(): + with helpers[helper]() as span: + assert span is INVALID_SPAN + assert trace_exporter.get_finished_spans() == () + + @pytest.mark.parametrize("helper", ["server", "client", "delegate"]) + def test_helper_emits_by_default( + self, helper: str, helpers, trace_exporter: InMemorySpanExporter + ): + with helpers[helper](): + pass + assert len(trace_exporter.get_finished_spans()) == 1 + + +class TestContextPropagation: + """`propagation_only` keeps trace context flowing; `off` does not.""" + + def test_extract_preserves_current_context_values(self): + """Regression: extracting the incoming traceparent must not discard + context values the caller already established. Extracting onto a fresh + root would drop FastMCP's own suppression marker (and any baggage), so + attaching the result would silently re-enable FastMCP's spans. + """ + with suppress_fastmcp_telemetry(): + parent = extract_trace_context({"traceparent": TRACEPARENT}) + token = otel_context.attach(parent) + try: + assert telemetry_mode() == "propagation_only" + finally: + otel_context.detach(token) + + def test_extract_applies_incoming_parent(self, mode): + mode("propagation_only") + parent = extract_trace_context({"traceparent": TRACEPARENT}) + token = otel_context.attach(parent) + try: + span_context = otel_trace.get_current_span().get_span_context() + assert format(span_context.trace_id, "032x") == ( + "4bf92f3577b34da6a3ce929d0e0e4736" + ) + finally: + otel_context.detach(token) + + def test_off_ignores_incoming_parent(self, mode): + """`off` is a full pass-through: the incoming context is not applied.""" + mode("off") + parent = extract_trace_context({"traceparent": TRACEPARENT}) + assert parent is otel_context.get_current() + + def test_off_does_not_inject(self, mode, trace_exporter: InMemorySpanExporter): + mode("off") + with otel_trace.get_tracer("test").start_as_current_span("root"): + assert inject_trace_context({"existing": 1}) == {"existing": 1} + + def test_propagation_only_still_injects( + self, mode, trace_exporter: InMemorySpanExporter + ): + mode("propagation_only") + with otel_trace.get_tracer("test").start_as_current_span("root"): + meta = inject_trace_context() + assert meta is not None and "traceparent" in meta + + +class TestEndToEnd: + """A real in-process client drives a real server — nothing monkeypatched + beyond the setting itself.""" + + async def test_propagation_only_parents_downstream_user_spans( + self, mode, trace_exporter: InMemorySpanExporter + ): + mode("propagation_only") + captured: dict[str, int] = {} + + server = FastMCP("interop-server") + + @server.tool + async def work(ctx: Context) -> str: + # A span the *user* creates inside their handler. + tracer = otel_trace.get_tracer("user-code") + with tracer.start_as_current_span("user-span") as span: + captured["downstream"] = span.get_span_context().trace_id + return "done" + + async with Client(server) as client: + tracer = otel_trace.get_tracer("client-code") + with tracer.start_as_current_span("client-root") as root: + captured["client"] = root.get_span_context().trace_id + await client.call_tool("work", {}) + + names = [s.name for s in trace_exporter.get_finished_spans()] + assert "user-span" in names and "client-root" in names + # FastMCP emitted none of its own spans — including the per-request + # SERVER span opened at the middleware seam, which is the whole point. + assert fastmcp_spans(trace_exporter) == [] + assert [ + s for s in trace_exporter.get_finished_spans() if s.kind == SpanKind.SERVER + ] == [] + # ...yet the user's span inherited the incoming distributed trace. + assert captured["client"] == captured["downstream"] + + async def test_propagation_only_without_incoming_trace( + self, mode, trace_exporter: InMemorySpanExporter + ): + """With no surrounding client span there is no incoming trace. The call + must still succeed and emit no FastMCP spans.""" + mode("propagation_only") + captured: dict[str, int] = {} + + server = FastMCP("interop-server") + + @server.tool + async def work(ctx: Context) -> str: + tracer = otel_trace.get_tracer("user-code") + with tracer.start_as_current_span("user-span") as span: + captured["downstream"] = span.get_span_context().trace_id + return "done" + + async with Client(server) as client: + result = await client.call_tool("work", {}) + + assert result.data == "done" + assert fastmcp_spans(trace_exporter) == [] + # A self-rooted trace was created (no incoming parent to inherit). + assert "downstream" in captured + + async def test_suppress_block_silences_a_single_call( + self, trace_exporter: InMemorySpanExporter + ): + """The scoped form suppresses one call and leaves the next instrumented.""" + server = FastMCP("interop-server") + + @server.tool + async def work() -> str: + return "done" + + async with Client(server) as client: + # Drop the spans the connection handshake already emitted. + trace_exporter.clear() + + with suppress_fastmcp_telemetry(): + await client.call_tool("work", {}) + assert fastmcp_spans(trace_exporter) == [] + + await client.call_tool("work", {}) + assert fastmcp_spans(trace_exporter) != [] diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index d85ea0322..042274212 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -5,6 +5,7 @@ from __future__ import annotations import pytest from mcp import MCPError from mcp_types import INTERNAL_ERROR, INVALID_PARAMS +from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY from fastmcp import Client, FastMCP from fastmcp.exceptions import ( @@ -71,6 +72,20 @@ class TestWireErrorCodes: assert exc_info.value.error.code == INVALID_PARAMS assert "Resource not found" in exc_info.value.error.message + async def test_resource_not_found_echoes_uri_in_data(self): + """SEP-2164 SHOULD: the error names which URI was missing. + + A client that pipelined several reads cannot otherwise tell which one + failed from the message alone. + """ + mcp = FastMCP("test-server") + + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource_mcp("config://missing") + + assert exc_info.value.error.data == {"uri": "config://missing"} + async def test_prompt_not_found_uses_invalid_params(self): mcp = FastMCP("test-server") @@ -80,3 +95,45 @@ class TestWireErrorCodes: assert exc_info.value.error.code == INVALID_PARAMS assert "Unknown prompt" in exc_info.value.error.message + + +class TestMissingClientCapabilityFromTool: + """A tool's `-32021` must reach the wire, not become an `isError` result. + + SEP-2575 makes this error a statement about the *request* — the server + cannot service it at all — so flattening it into a tool result would drop + the code and tell the client the call succeeded. Every other `MCPError` + raised under a tool still masks into a result, since those describe how the + call went rather than whether it could run. + """ + + @staticmethod + def _server() -> FastMCP: + mcp = FastMCP("capability-test") + + @mcp.tool + async def needs_sampling() -> str: + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message="Client did not declare the required 'sampling' capability", + data={"requiredCapabilities": {"sampling": {}}}, + ) + + @mcp.tool + async def upstream_failed() -> str: + raise MCPError(code=INTERNAL_ERROR, message="upstream exploded") + + return mcp + + async def test_capability_error_propagates_as_protocol_error(self): + async with Client(self._server()) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("needs_sampling") + + assert exc_info.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data == {"requiredCapabilities": {"sampling": {}}} + + async def test_other_mcp_errors_still_become_tool_errors(self): + async with Client(self._server()) as client: + with pytest.raises(ToolError): + await client.call_tool("upstream_failed") diff --git a/tests/test_fastmcp_app.py b/tests/test_fastmcp_app.py index 86f9eae60..9737454ff 100644 --- a/tests/test_fastmcp_app.py +++ b/tests/test_fastmcp_app.py @@ -22,6 +22,7 @@ from fastmcp.apps.app import ( FastMCPApp, _make_resolver, ) +from fastmcp.server.providers.addressing import hash_tool, hashed_backend_name from fastmcp.tools.base import Tool # --------------------------------------------------------------------------- @@ -579,13 +580,13 @@ class TestCallToolAppRouting: # --------------------------------------------------------------------------- -# App-only tool filtering from server list_tools / get_tool +# App-only tool visibility: declared in meta, listed on the wire # --------------------------------------------------------------------------- -class TestAppOnlyToolFiltering: - async def test_app_only_tool_hidden_from_list_tools(self): - """@app.tool() (visibility=["app"]) should not appear in server.list_tools().""" +class TestAppOnlyToolVisibility: + async def test_app_only_tool_appears_in_list_tools(self): + """@app.tool() (visibility=["app"]) is listed; the host filters it out.""" app = FastMCPApp("crm") @app.tool() @@ -597,7 +598,40 @@ class TestAppOnlyToolFiltering: tools = await server.list_tools() names = [t.name for t in tools] - assert "save_contact" not in names + assert "save_contact" in names + + async def test_app_only_tool_declares_app_visibility(self): + """The listed tool carries visibility=["app"] so a host can filter it.""" + app = FastMCPApp("crm") + + @app.tool() + def save_contact(name: str) -> str: + return name + + server = FastMCP("Platform") + server.add_provider(app) + + tool = next(t for t in await server.list_tools() if t.name == "save_contact") + assert tool.meta is not None + assert tool.meta["ui"]["visibility"] == ["app"] + + async def test_app_only_tool_visibility_survives_the_wire(self): + """A client sees the visibility declaration, which is what it filters on.""" + app = FastMCPApp("crm") + + @app.tool() + def save_contact(name: str) -> str: + return name + + server = FastMCP("Platform") + server.add_provider(app) + + async with Client(server) as client: + tool = next( + t for t in await client.list_tools() if t.name == "save_contact" + ) + assert tool.meta is not None + assert tool.meta["ui"]["visibility"] == ["app"] async def test_model_visible_tool_in_list_tools(self): """@app.tool(model=True) (visibility=["app","model"]) appears in list_tools.""" @@ -629,8 +663,8 @@ class TestAppOnlyToolFiltering: names = [t.name for t in tools] assert "show_dashboard" in names - async def test_app_only_tool_still_callable_via_app_name(self): - """Even though filtered from list_tools, app-only tools are callable via call_tool with app_name.""" + async def test_app_only_tool_callable_via_hashed_address(self): + """The hashed address still resolves, independent of the display name.""" app = FastMCPApp("contacts") @app.tool() @@ -640,35 +674,30 @@ class TestAppOnlyToolFiltering: server = FastMCP("Platform") server.add_provider(app) - # Verify it's hidden from list_tools - tools = await server.list_tools() - names = [t.name for t in tools] - assert "save" not in names - - # But still callable via the hashed-address routing path. - from fastmcp.server.providers.addressing import hashed_backend_name - result = await server.call_tool( hashed_backend_name("contacts", "save"), {"name": "alice"} ) assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - async def test_app_only_tool_hidden_from_get_tool(self): - """server.get_tool() returns None for app-only tools.""" - app = FastMCPApp("crm") + async def test_app_only_tool_callable_by_display_name(self): + """App-only tools resolve normally; the host decides who may call them.""" + app = FastMCPApp("contacts") @app.tool() - def save_contact(name: str) -> str: - return name + def save(name: str) -> str: + return f"saved {name}" server = FastMCP("Platform") server.add_provider(app) - tool = await server.get_tool("save_contact") - assert tool is None + tool = await server.get_tool("save") + assert tool is not None - async def test_app_only_tool_hidden_with_namespace(self): - """App-only tools hidden even when accessed through a namespace.""" + result = await server.call_tool("save", {"name": "alice"}) + assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_app_only_tool_namespaced_in_list_tools(self): + """Namespacing renames app-only tools like any other tool.""" app = FastMCPApp("crm") @app.tool() @@ -680,7 +709,23 @@ class TestAppOnlyToolFiltering: tools = await server.list_tools() names = [t.name for t in tools] - assert "crm_save" not in names + assert "crm_save" in names + + async def test_app_only_tool_carries_public_hash(self): + """The identity hash is public meta, so intermediaries can match on it.""" + app = FastMCPApp("crm") + + @app.tool() + def save(name: str) -> str: + return name + + server = FastMCP("Platform") + server.add_provider(app, namespace="crm") + + async with Client(server) as client: + tool = next(t for t in await client.list_tools() if t.name == "crm_save") + assert tool.meta is not None + assert tool.meta["fastmcp"]["tool_hash"] == hash_tool("crm", "save") # --------------------------------------------------------------------------- @@ -872,21 +917,25 @@ class TestAppIntegration: server = FastMCP("Platform") server.add_provider(app, namespace="crm") - # The @app.ui() tool should be visible (namespaced) to the client. - # The @app.tool() backend tool should NOT appear. + # Both tools are listed (namespaced). The backend tool declares + # visibility=["app"] so the host keeps it out of the model's list. async with Client(server) as client: tools = await client.list_tools() tool_names = [t.name for t in tools] assert "crm_contact_form" in tool_names - assert "crm_save_contact" not in tool_names + assert "crm_save_contact" in tool_names + + backend = next(t for t in tools if t.name == "crm_save_contact") + assert backend.meta is not None + assert backend.meta["ui"]["visibility"] == ["app"] # Call the UI tool through the client and check structured_content result = await client.call_tool_mcp("crm_contact_form", {}) sc = result.structured_content assert sc is not None - # Call the backend tool via its hashed address — bypasses namespace - # transforms and visibility filtering by going through the registry. + # Call the backend tool via its hashed address — resolves regardless + # of the namespace transform applied to the display name. backend_result = await server.call_tool( hashed_backend_name("contacts", "save_contact"), {"name": "Alice", "email": "alice@example.com"}, diff --git a/tests/test_upgrade_from_v3.py b/tests/test_upgrade_from_v3.py index f324241ba..2f076d055 100644 --- a/tests/test_upgrade_from_v3.py +++ b/tests/test_upgrade_from_v3.py @@ -34,18 +34,35 @@ from fastmcp import Client, FastMCP, settings # longer resolves. `create_proxy`, `settings`, `McpError`, and # `CacheableToolResult` above are part of the same set. from fastmcp.apps import AppConfig +from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler from fastmcp.client.transports import StreamableHttpTransport from fastmcp.dependencies import Depends from fastmcp.exceptions import McpError from fastmcp.prompts.function_prompt import FunctionPrompt from fastmcp.resources.function_resource import FunctionResource from fastmcp.server import create_proxy +from fastmcp.server.auth import ( + AuthCheck, + AuthContext, + require_roles, + require_scopes, + restrict_tag, + run_auth_checks, +) from fastmcp.server.middleware.caching import CacheableToolResult from fastmcp.server.providers.openapi import OpenAPIProvider from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient from fastmcp.server.transforms import PromptsAsTools, ResourcesAsTools, ToolTransform from fastmcp.tools.function_tool import FunctionTool +# The two authorization names the removed shim exported that `fastmcp.server.auth` +# deliberately does not re-export (middleware plumbing, no documented user-facing +# use). The upgrade guide sends them here instead, so pin that path too. +from fastmcp.utilities.authorization import ( + run_auth_checks_with_shortfall, + scope_requirements, +) + class TestCommonServersUpgradeCleanly: """Servers written against the 3.x API run unchanged on v4 defaults.""" @@ -152,6 +169,38 @@ class TestCanonicalReplacementsResolve: ) assert all(sym is not None for sym in symbols) + def test_authorization_symbols_resolve_from_their_documented_paths(self): + # The removed `fastmcp.server.auth.authorization` shim exported eight + # names, and the upgrade guide splits them across two replacements. Pin + # both halves: the checks users write against reach the auth package, + # while the two middleware helpers stay on the utilities module. + from_auth_package = ( + AuthCheck, + AuthContext, + require_roles, + require_scopes, + restrict_tag, + run_auth_checks, + ) + from_utilities = (run_auth_checks_with_shortfall, scope_requirements) + assert all(sym is not None for sym in from_auth_package + from_utilities) + + import fastmcp.server.auth as auth_package + + for name in ("run_auth_checks_with_shortfall", "scope_requirements"): + assert not hasattr(auth_package, name) + + def test_sampling_handler_resolves_from_its_submodule(self): + # The removed shim re-exported `OpenAISamplingHandler` from its package + # `__init__`. The canonical package keeps its `__init__` empty so that + # touching it never pulls in a vendor SDK, so the guide must name the + # submodule — pin both halves of that. + assert OpenAISamplingHandler is not None + + import fastmcp.client.sampling.handlers as handlers_package + + assert not hasattr(handlers_package, "OpenAISamplingHandler") + # --- Hard removals: modules that no longer exist --- @@ -162,7 +211,15 @@ REMOVED_MODULES = [ "fastmcp.experimental.utilities.openapi", # -> fastmcp.utilities.openapi "fastmcp.server.apps", # -> fastmcp.apps "fastmcp.server.app", # -> fastmcp.apps / fastmcp - "mcp.types", # -> mcp_types + # The pre-rename component modules. `tool.py`/`resource.py`/`prompt.py` are + # now `base.py`; import the types from the package itself (`from + # fastmcp.tools import Tool`) rather than naming the private module. + "fastmcp.tools.tool", # -> fastmcp.tools + "fastmcp.resources.resource", # -> fastmcp.resources + "fastmcp.prompts.prompt", # -> fastmcp.prompts + "fastmcp.experimental.sampling", # -> fastmcp.client.sampling + "fastmcp.experimental.sampling.handlers", # -> fastmcp.client.sampling.handlers + "fastmcp.server.auth.authorization", # -> fastmcp.server.auth / fastmcp.utilities.authorization ] # Names that were re-export shims and are gone; import them from the canonical @@ -174,10 +231,9 @@ REMOVED_NAMES = [ # old misspelled names renamed to Cacheable* (no alias) codespell:ignore ("fastmcp.server.middleware.caching", "CachableToolResult"), # codespell:ignore ("fastmcp.server.middleware.caching", "CachablePromptResult"), # codespell:ignore - # component-import shims -> fastmcp.tools.function_tool, etc. - ("fastmcp.tools.tool", "FunctionTool"), - ("fastmcp.resources.resource", "FunctionResource"), - ("fastmcp.prompts.prompt", "FunctionPrompt"), + # 3.0-era rename alias -> SkillsDirectoryProvider + ("fastmcp.server.providers.skills", "SkillsProvider"), + ("fastmcp.server.providers", "SkillsProvider"), ] @@ -187,6 +243,20 @@ class TestRemovedSurfacesFailLoudly: with pytest.raises(ModuleNotFoundError): importlib.import_module(module_path) + def test_mcp_types_import_path_restored_by_stable_sdk(self): + # The MCP Python SDK beta (2.0.0b2, what v4 was built against) dropped + # `mcp.types` entirely, so `from mcp.types import X` was documented as a + # hard break requiring a switch to `from mcp_types import X`. The stable + # SDK release (2.0.0) reintroduced `mcp.types` as a deliberate mirror of + # `mcp_types` — same objects, same snake_case fields, not a v1 API + # restoration — specifically so old import paths keep working. Both + # spellings resolve to the identical class. + import mcp.types + import mcp_types + + assert mcp.types.Tool is mcp_types.Tool + assert set(mcp.types.__all__) == set(mcp_types.__all__) + @pytest.mark.parametrize( "module_path, name", REMOVED_NAMES, diff --git a/tests/tools/test_standalone_decorator.py b/tests/tools/test_standalone_decorator.py index 6b79001cc..fc90ae13f 100644 --- a/tests/tools/test_standalone_decorator.py +++ b/tests/tools/test_standalone_decorator.py @@ -25,7 +25,7 @@ from fastmcp.tools.function_tool import DecoratedTool, FunctionTool, ToolMeta "from fastmcp.resources import Resource, resource", "from fastmcp.prompts import Prompt, prompt", "import sys; import fastmcp.apps.config; assert 'fastmcp.tools.function_tool' not in sys.modules", - "from fastmcp.server.auth.authorization import AuthCheck", + "from fastmcp.server.auth import AuthCheck", "from fastmcp.server import Context, FastMCP, create_proxy", ], ) diff --git a/tests/tools/tool/test_results.py b/tests/tools/tool/test_results.py index 39c690569..ebe26700b 100644 --- a/tests/tools/tool/test_results.py +++ b/tests/tools/tool/test_results.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field from fastmcp import Client, FastMCP from fastmcp.tools.base import Tool, ToolResult +from tests.conftest import user_meta class TestToolResultCasting: @@ -39,7 +40,7 @@ class TestToolResultCasting: assert result.content[0].type == "text" assert result.content[0].text == "test data" assert result.structured_content is None - assert result.meta is None + assert user_meta(result.meta) is None async def test_neither_unstructured_or_structured_content(self, client): from fastmcp.exceptions import ToolError @@ -56,7 +57,7 @@ class TestToolResultCasting: assert result.content[0].type == "text" assert result.content[0].text == "test data" assert result.structured_content == {"data_type": "test"} - assert result.meta is None + assert user_meta(result.meta) is None async def test_structured_unstructured_and_meta_content(self, client): result = await client.call_tool( @@ -71,7 +72,7 @@ class TestToolResultCasting: assert result.content[0].type == "text" assert result.content[0].text == "test data" assert result.structured_content == {"data_type": "test"} - assert result.meta == {"some": "metadata"} + assert user_meta(result.meta) == {"some": "metadata"} class TestToolResultIsError: @@ -153,7 +154,12 @@ class TestToolResultIsError: async with Client(mcp) as client: result = await client.call_tool_mcp("failing", {}) - assert result.model_dump(by_alias=True) == raw_result.model_dump(by_alias=True) + received = result.model_dump(by_alias=True) + # The SDK stamps `serverInfo` into every 2026-era result's `_meta` + # (spec #3002). Strip it so the assertion covers the protocol fields + # the tool itself set, which is what FastMCP is responsible for. + received["_meta"] = user_meta(received["_meta"]) + assert received == raw_result.model_dump(by_alias=True) class TestUnionReturnTypes: diff --git a/tests/tools/tool_transform/test_metadata.py b/tests/tools/tool_transform/test_metadata.py index 36384afec..543339018 100644 --- a/tests/tools/tool_transform/test_metadata.py +++ b/tests/tools/tool_transform/test_metadata.py @@ -172,6 +172,45 @@ def test_tool_transform_config_removes_meta(sample_tool): assert transformed.meta is None +def test_meta_override_preserves_fastmcp_namespace(sample_tool): + """A meta override replaces caller meta but keeps framework-owned data. + + The fastmcp namespace carries app membership and the identity hash that + intermediaries match on. A rename via config must not destroy it. + """ + sample_tool.meta = {"original": True, "fastmcp": {"app": "crm", "tool_hash": "abc"}} + transformed = Tool.from_tool(sample_tool, meta={"custom": True}) + assert transformed.meta == { + "custom": True, + "fastmcp": {"app": "crm", "tool_hash": "abc"}, + } + + +def test_meta_none_preserves_fastmcp_namespace(sample_tool): + """Clearing meta clears caller meta, not the framework namespace.""" + sample_tool.meta = {"original": True, "fastmcp": {"app": "crm", "tool_hash": "abc"}} + transformed = Tool.from_tool(sample_tool, meta=None) + assert transformed.meta == {"fastmcp": {"app": "crm", "tool_hash": "abc"}} + + +def test_meta_override_can_extend_fastmcp_namespace(sample_tool): + """An override may add to the fastmcp namespace without dropping its keys.""" + sample_tool.meta = {"fastmcp": {"app": "crm", "tool_hash": "abc"}} + transformed = Tool.from_tool(sample_tool, meta={"fastmcp": {"extra": 1}}) + assert transformed.meta == { + "fastmcp": {"app": "crm", "tool_hash": "abc", "extra": 1} + } + + +def test_config_meta_override_preserves_identity_hash(sample_tool): + """The fastmcp.json `tools:` path goes through the same preservation.""" + sample_tool.meta = {"fastmcp": {"app": "crm", "tool_hash": "abc"}} + config = ToolTransformConfig(name="renamed", meta={"team": "growth"}) + transformed = config.apply(sample_tool) + assert transformed.meta is not None + assert transformed.meta["fastmcp"]["tool_hash"] == "abc" + + # Enabled field tests def test_tool_transform_config_enabled_defaults_to_true(sample_tool): """Test that enabled defaults to True and no visibility metadata is set.""" diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 8d16665da..b97153b02 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -1,4 +1,6 @@ import copy +import sys +from typing import Any from unittest.mock import patch from jsonref import replace_refs @@ -12,6 +14,31 @@ from fastmcp.utilities.json_schema import ( ) +def _measure_depth(schema: dict[str, Any]) -> int: + """Return how many `items` levels deep an array-nested schema goes. + + Walks iteratively so the assertion helpers cannot themselves hit the + recursion limit the tests are probing. + """ + depth = 0 + node: Any = schema + while isinstance(node.get("items"), dict): + node = node["items"] + depth += 1 + return depth + + +def _count_titles(schema: dict[str, Any]) -> int: + """Count the `title` keys down an array-nested schema, iteratively.""" + count = 0 + node: Any = schema + while isinstance(node, dict): + if "title" in node: + count += 1 + node = node.get("items") + return count + + class TestPruneParam: """Tests for the _prune_param function.""" @@ -391,6 +418,51 @@ class TestCompressSchema: assert "additionalProperties" not in result assert "$defs" not in result + def test_compresses_schema_nested_far_beyond_the_recursion_limit(self): + """Deeply nested schemas must compress rather than raise RecursionError. + + Copying the schema is what sets the depth ceiling, so it must not + recurse: schemas this deep arrive from proxied or remote MCP servers, + and failing to compress them is worse than compressing them partially. + """ + depth = sys.getrecursionlimit() * 2 + + schema: dict[str, Any] = {"type": "string", "title": "Leaf"} + for _ in range(depth): + schema = {"type": "array", "title": "Level", "items": schema} + + original_depth = _measure_depth(schema) + + result = compress_schema(schema, prune_titles=True) + + assert result is not schema + assert _measure_depth(result) == original_depth + # The caller's schema is still intact at every level... + assert _count_titles(schema) == original_depth + 1 + # ...and the copy really was pruned as deep as the traversal reaches. + assert _count_titles(result) < _count_titles(schema) + + def test_keeps_defs_referenced_below_the_traversal_cutoff(self): + """A $ref deeper than the traversal walks must still pin its definition. + + The reference scan stops at its depth guard, so past that point it + cannot prove a definition is unused. Dropping one anyway would leave a + dangling $ref — an invalid schema is worse than an unpruned one. + """ + schema: dict[str, Any] = {"$ref": "#/$defs/Leaf"} + for _ in range(60): + schema = {"type": "array", "items": schema} + schema["$defs"] = {"Leaf": {"type": "string"}} + + result = compress_schema(schema) + + assert result["$defs"] == {"Leaf": {"type": "string"}} + + node: Any = result + while isinstance(node.get("items"), dict): + node = node["items"] + assert node == {"$ref": "#/$defs/Leaf"} + def test_preserves_refs_by_default(self): """Test that compress_schema preserves $refs by default.""" schema = { diff --git a/tests/utilities/test_types.py b/tests/utilities/test_types.py index 85aa8c2be..5ad8d16f7 100644 --- a/tests/utilities/test_types.py +++ b/tests/utilities/test_types.py @@ -282,10 +282,25 @@ class TestAudio: assert audio.data == b"test" assert audio._mime_type == "audio/wav" # Default for raw data + def test_mime_type_from_format(self): + """Test MIME type normalization from audio format.""" + + expected = { + "wav": "audio/wav", + "mp3": "audio/mpeg", + "ogg": "audio/ogg", + "m4a": "audio/mp4", + "flac": "audio/flac", + } + + for fmt, mime in expected.items(): + audio = Audio(data=b"test", format=fmt) + assert audio._mime_type == mime + def test_audio_initialization_with_format(self): """Test audio initialization with a specific format.""" audio = Audio(data=b"test", format="mp3") - assert audio._mime_type == "audio/mp3" + assert audio._mime_type == "audio/mpeg" def test_missing_data_and_path_raises_error(self): """Test that error is raised when neither path nor data is provided.""" @@ -335,7 +350,7 @@ class TestAudio: content = audio.to_audio_content() assert content.type == "audio" - assert content.mime_type == "audio/mp3" + assert content.mime_type == "audio/mpeg" assert content.data == base64.b64encode(test_data).decode() def test_to_audio_content_error(self, monkeypatch): diff --git a/uv.lock b/uv.lock index 7bf07c4f6..a169584d7 100644 --- a/uv.lock +++ b/uv.lock @@ -4,25 +4,21 @@ requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version < '3.11'", ] [options] -exclude-newer = "2026-07-17T00:42:32.518243Z" +exclude-newer = "2026-07-21T14:39:05.08339Z" exclude-newer-span = "P1W" [options.exclude-newer-package] mcp-types = false -prefab-ui = false -truststore = false -fastmcp-slim = false fastmcp = false +prefab-ui = false mcp = false -httpcore2 = false fastmcp-remote = false -httpx2 = false +fastmcp-slim = false [manifest] members = [ @@ -36,14 +32,34 @@ members = [ name = "aiofile" version = "3.9.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ - { name = "caio" }, + { name = "caio", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, ] +[[package]] +name = "aiofile" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", +] +dependencies = [ + { name = "caio", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -64,7 +80,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.87.0" +version = "0.117.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -76,32 +92,32 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/8f/3281edf7c35cbac169810e5388eb9b38678c7ea9867c2d331237bd5dff08/anthropic-0.87.0.tar.gz", hash = "sha256:098fef3753cdd3c0daa86f95efb9c8d03a798d45c5170329525bb4653f6702d0", size = 588982, upload-time = "2026-03-31T17:52:41.697Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/0d/8f71d535edb0d438f023bd825fb65f67c14fa88a2bd6b75f292a58a63de4/anthropic-0.117.0.tar.gz", hash = "sha256:98107f2b76439641e0ae2a1754087534b8f178dbab99d6eb1bc4b7bc8c744496", size = 989933, upload-time = "2026-07-16T19:36:13.07Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/02/99bf351933bdea0545a2b6e2d812ed878899e9a95f618351dfa3d0de0e69/anthropic-0.87.0-py3-none-any.whl", hash = "sha256:e2669b86d42c739d3df163f873c51719552e263a3d85179297180fb4fa00a236", size = 472126, upload-time = "2026-03-31T17:52:40.174Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4c/917d21d6619a4475cdafc6d13a69fdb3b901ddac57e76caca5a25c117b6d/anthropic-0.117.0-py3-none-any.whl", hash = "sha256:451a0a6905f11dff7663d13e4ee5dbf909eb8942b1d049803c7b937a13ac47ec", size = 998327, upload-time = "2026-07-16T19:36:11.225Z" }, ] [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] name = "asttokens" -version = "3.0.1" +version = "3.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, ] [[package]] @@ -137,15 +153,15 @@ wheels = [ [[package]] name = "azure-core" -version = "1.39.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/83/bbde3faa84ddcb8eb0eca4b3ffb3221252281db4ce351300fe248c5c70b1/azure_core-1.39.0.tar.gz", hash = "sha256:8a90a562998dd44ce84597590fff6249701b98c0e8797c95fcdd695b54c35d74", size = 367531, upload-time = "2026-03-19T01:31:29.461Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, ] [[package]] @@ -193,7 +209,7 @@ wheels = [ [[package]] name = "black" -version = "26.3.1" +version = "26.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -205,34 +221,34 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, - { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, - { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, - { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, - { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, - { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, - { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, - { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, - { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, - { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, - { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, - { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, - { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, - { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, + { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] [[package]] @@ -253,11 +269,11 @@ wheels = [ [[package]] name = "cachetools" -version = "7.0.5" +version = "7.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, ] [[package]] @@ -291,210 +307,220 @@ wheels = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, + { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.6" +version = "3.4.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, - { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, - { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, - { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, - { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, - { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, - { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, - { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, - { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, - { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, - { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, - { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, - { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, - { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, - { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, - { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, - { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, - { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, - { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, - { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, - { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, - { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, - { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, - { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, - { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, - { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, - { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, - { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, - { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, - { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, - { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, - { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, - { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, - { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, - { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, - { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, - { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, - { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, - { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, - { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, - { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, - { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, - { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, - { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, - { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -517,115 +543,100 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.5" +version = "7.15.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, - { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, - { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, - { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, - { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, - { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, - { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, - { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, - { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, - { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, - { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, + { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, + { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, + { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, + { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, + { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, ] [package.optional-dependencies] @@ -700,7 +711,7 @@ wheels = [ [[package]] name = "cyclopts" -version = "4.10.1" +version = "4.22.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -710,18 +721,18 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/c4/2ce2ca1451487dc7d59f09334c3fa1182c46cfcf0a2d5f19f9b26d53ac74/cyclopts-4.10.1.tar.gz", hash = "sha256:ad4e4bb90576412d32276b14a76f55d43353753d16217f2c3cd5bdceba7f15a0", size = 166623, upload-time = "2026-03-23T14:43:01.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/47/32d992e829f63aedea5b93360db23c8882c9bbbde094bcf0fff899ea8a3b/cyclopts-4.22.1.tar.gz", hash = "sha256:49cd3779da7113a96ac5c23b151aa61ac9ae1b4b1fe813594d207ca843c97892", size = 193551, upload-time = "2026-07-20T17:38:59.38Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/2261922126b2e50c601fe22d7ff5194e0a4d50e654836260c0665e24d862/cyclopts-4.10.1-py3-none-any.whl", hash = "sha256:35f37257139380a386d9fe4475e1e7c87ca7795765ef4f31abba579fcfcb6ecd", size = 204331, upload-time = "2026-03-23T14:43:02.625Z" }, + { url = "https://files.pythonhosted.org/packages/9a/89/f2710638cd2f824cb976777e63ef6ed1e83fc56888cf5c9f5a053490b7be/cyclopts-4.22.1-py3-none-any.whl", hash = "sha256:9b614e231075aee9849c0bfd78f7611ab7adf417f16af5b9e42b9ed6e18c17d1", size = 232953, upload-time = "2026-07-20T17:38:58.078Z" }, ] [[package]] name = "decorator" -version = "5.2.1" +version = "5.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] [[package]] @@ -753,20 +764,11 @@ wheels = [ [[package]] name = "docstring-parser" -version = "0.17.0" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, -] - -[[package]] -name = "docutils" -version = "0.22.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] [[package]] @@ -827,7 +829,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.2" +version = "0.139.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -836,9 +838,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, ] [[package]] @@ -879,8 +881,7 @@ dev = [ { name = "fastmcp-remote" }, { name = "inline-snapshot", extra = ["dirty-equals"] }, { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ipython", version = "9.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "loq" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-sdk" }, @@ -1049,10 +1050,10 @@ requires-dist = [ { name = "jsonref", marker = "extra == 'gemini'", specifier = ">=1.1.0" }, { name = "jsonref", marker = "extra == 'server'", specifier = ">=1.1.0" }, { name = "jsonschema-path", marker = "extra == 'server'", specifier = ">=0.3.4" }, - { name = "mcp", marker = "extra == 'client'", specifier = "==2.0.0b2" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = "==2.0.0b2" }, - { name = "mcp", marker = "extra == 'server'", specifier = "==2.0.0b2" }, - { name = "mcp-types", specifier = "==2.0.0b2" }, + { name = "mcp", marker = "extra == 'client'", specifier = ">=2.0.0,<3.0.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2.0.0,<3.0.0" }, + { name = "mcp", marker = "extra == 'server'", specifier = ">=2.0.0,<3.0.0" }, + { name = "mcp-types", specifier = ">=2.0.0,<3.0.0" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" }, { name = "openapi-pydantic", marker = "extra == 'server'", specifier = ">=0.5.1" }, { name = "opentelemetry-api", marker = "extra == 'client'", specifier = ">=1.28.0" }, @@ -1101,15 +1102,15 @@ requires-dist = [ [[package]] name = "google-auth" -version = "2.49.1" +version = "2.55.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b9/e370d86fea3da13ec0256df30323dd26c0cb9c8c85f0c6ec42ac9df0106b/google_auth-2.55.2.tar.gz", hash = "sha256:97ae7790ff740f2bc9db60eb864a7804f4ac19f5f02c38b3d942f2fea6e9b9ae", size = 361414, upload-time = "2026-07-07T18:43:21.227Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c6/02eb5a337ac316a4c30c012e747bad5cea36e1a876efecdf80865541f7d8/google_auth-2.55.2-py3-none-any.whl", hash = "sha256:d715f265f2cafc6a5f1bf0dc19870d20e3119f6f6682785a250bce3d03d38a3b", size = 256778, upload-time = "2026-07-07T18:43:19.52Z" }, ] [package.optional-dependencies] @@ -1119,7 +1120,7 @@ requests = [ [[package]] name = "google-genai" -version = "1.69.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1133,91 +1134,91 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/5e/c0a5e6ff60d18d3f19819a9b1fbd6a1ef2162d025696d8660550739168dc/google_genai-1.69.0.tar.gz", hash = "sha256:5f1a6a478e0c5851506a3d337534bab27b3c33120e27bf9174507ea79dfb8673", size = 519538, upload-time = "2026-03-28T15:33:27.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/59/9ea84cbeb8f09694564d3b0ee9dd59003551b308d47b61f251415df93982/google_genai-2.12.1.tar.gz", hash = "sha256:78c25217885d63dc430ca7c4526853512b164a25a93a8a0d0af5b85971aa1db0", size = 636710, upload-time = "2026-07-16T16:15:02.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/58/ef0586019f54b2ebb36deed7608ccb5efe1377564d2aaea6b1e295d1fadc/google_genai-1.69.0-py3-none-any.whl", hash = "sha256:252e714d724aba74949647b9de511a6a6f7804b3b317ab39ddee9cc2f001cacc", size = 760551, upload-time = "2026-03-28T15:33:24.957Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b4/1369fb413fc2ba7f78acace5590b6e9990c52ab5d1d166aafaa1ae2c28c8/google_genai-2.12.1-py3-none-any.whl", hash = "sha256:686d5ec39bda345151d3ed1bac3915f01f49138b1ea519af2eb98f11cc55ebc4", size = 1023403, upload-time = "2026-07-16T16:14:59.79Z" }, ] [[package]] name = "googleapis-common-protos" -version = "1.73.1" +version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/c0/4a54c386282c13449eca8bbe2ddb518181dc113e78d240458a68856b4d69/googleapis_common_protos-1.73.1.tar.gz", hash = "sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6", size = 147506, upload-time = "2026-03-26T22:17:38.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] [[package]] name = "griffelib" -version = "2.0.2" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, ] [[package]] name = "grpcio" -version = "1.78.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" }, - { url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" }, - { url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" }, - { url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" }, - { url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" }, - { url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" }, - { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, - { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, - { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, - { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, - { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, - { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, - { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, - { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, - { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, - { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, - { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, - { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, - { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, - { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, - { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, - { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, - { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, - { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, - { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, - { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, - { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, - { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, - { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, - { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, + { url = "https://files.pythonhosted.org/packages/a3/14/5d05bfd85c101cbe44a12d7c1cea9c40698e0438cddf3a70019f735b5a27/grpcio-1.82.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:91859d1cac5f47caec5fc40e9f827500cdb54ce5b36450dc9a65616b5af49c17", size = 6177087, upload-time = "2026-07-08T12:34:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/19/2e/c906f8e6d0b54c0137885fff6f7b5883c6bbc381b44a0ba5ea07d7d1579b/grpcio-1.82.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c80c9741dcef192f669876a81957cf7713b441c2f0c43631350d75fa49321d31", size = 11960907, upload-time = "2026-07-08T12:34:10.583Z" }, + { url = "https://files.pythonhosted.org/packages/de/be/ec4aa76cdf25539b9e960cbb9d5739f892ea6cde58078b5293860c1159d3/grpcio-1.82.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b89cff456796d2f0581783726ad017a2c70aff2d27b0f05504c34e2e417f7560", size = 6754802, upload-time = "2026-07-08T12:34:13.082Z" }, + { url = "https://files.pythonhosted.org/packages/e6/dd/47519c2a8fd9db47ec4493f44bd9f5b0175307e07089b1132e54b7b5b19c/grpcio-1.82.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d6e8a08f7038ba7a77f71e250804e4aba84fe91d22cfc54ff43c07b7529c4728", size = 7484535, upload-time = "2026-07-08T12:34:15.164Z" }, + { url = "https://files.pythonhosted.org/packages/63/99/659711e9689c4dd553bcd4eacff9cb9f458f34b60edf7afb3bbc1b0a58a2/grpcio-1.82.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:50fd2fe83426b1b1c6cdc4d72d555223b7dddf8ce07c5bac218b13fc6d684c6f", size = 6919066, upload-time = "2026-07-08T12:34:17.367Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/f2b772356b4f593ffe439795509fcbf675b0ff98211ae8ce2a180f2e559f/grpcio-1.82.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b758540a24d5394a9c578bf9f6126389f474b106ac3d9df1d53de56cb14c9fd9", size = 7525855, upload-time = "2026-07-08T12:34:19.479Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/b28cfffb989a84d8272593498bddd2d68148cce1813ad55189c469b0f1f8/grpcio-1.82.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c4ba4aac238f685743575d9d700003ac16537cce26e7c774993134f530652464", size = 8565122, upload-time = "2026-07-08T12:34:21.951Z" }, + { url = "https://files.pythonhosted.org/packages/97/f9/54956cb0c701190cbc9d7e535c3f84acf0285c6b9ed198a902766e17c3cd/grpcio-1.82.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed6fc621d6f366c88a60f0b971d5afd21d441d9aa561ee688de5b7acdb2cf901", size = 7933872, upload-time = "2026-07-08T12:34:24.539Z" }, + { url = "https://files.pythonhosted.org/packages/76/85/5f9cd1f965bbe4329556a212f178ae0c072b18b446cae05ed32fa8847c53/grpcio-1.82.1-cp310-cp310-win32.whl", hash = "sha256:bd2f45e46fff5b91c10997d0743a987517a7dde67c64c592835c2dcaac66f587", size = 4257373, upload-time = "2026-07-08T12:34:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/c4f42f7c69c53d27ed41643421b55908bcbe885b68f5a208135c72917c98/grpcio-1.82.1-cp310-cp310-win_amd64.whl", hash = "sha256:5e171d5f0d6a0af78ea7512783f170a44f80c165259d8773e3a354a7f991f2b5", size = 5006571, upload-time = "2026-07-08T12:34:28.778Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/e5092af97fa671ca279b3e373251af4bf87d5fbda7dc85f6a616899562a7/grpcio-1.82.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:0ddb18a9a9e1f46692b3567ae4abb3f8d117ce6afea48650f8eca06d8ab5d06f", size = 6181472, upload-time = "2026-07-08T12:34:31.009Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/18053a3a2ca03d0c2a1b8cc7271e705007a16aa5dae84bac00935c5b1a7f/grpcio-1.82.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cf855b1af246720f567b0ce5d0724d45dfa4188eecc3296a2a69257b11b9e94b", size = 11970995, upload-time = "2026-07-08T12:34:33.603Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/21b1acb052876ad00959ec4d1b05fe08607d650bcfa282073bb164c2703c/grpcio-1.82.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb30cb13e25bc13cea70ffc69d6d90c49d36ea6c1d4549e6912f70177834cac", size = 6760127, upload-time = "2026-07-08T12:34:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/3e/12/25eef9c245c54f0061317d13a302357fe8ea03bac240b2b02ececcf54da4/grpcio-1.82.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1e822b2774f719c017cbe700b6e47173b6ae290fb84906f52a5a3c2c60b62e1e", size = 7484377, upload-time = "2026-07-08T12:34:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/1a348767eb9d9bd7765dc4fa8a01723d3bb386d67f981ee5c6f9c02b8b1c/grpcio-1.82.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5dafb1ece8ed45dee7c738f166ec82e19673221ed5ab8967f72858a4685345b2", size = 6924269, upload-time = "2026-07-08T12:34:40.583Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b9/3aae7a03d34c86ea27988db859a6087c186f6c3f53f9b551e07afd989bfa/grpcio-1.82.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06503106e7271e0a49fd5a1ac04747f1e47e87d900476db6fe45bc87ee411f4", size = 7531848, upload-time = "2026-07-08T12:34:43.277Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/3c4afa625d0dac9090707966916284c035fc5b2fb3e2c51e156accee6735/grpcio-1.82.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ff99bc8cafb6a952201c37b995f425e641c93ffa6e072258525feab57290141d", size = 8568217, upload-time = "2026-07-08T12:34:45.502Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d8489c628e73e20a3d034e7f66912de7b1acb405f01d388f056a88e47924/grpcio-1.82.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:644ae1b94266ac785330f4590a69e52b6a7eb73029043a02209db81c81397d69", size = 7938771, upload-time = "2026-07-08T12:34:48.323Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b7/0a92cfd1658f3a896d4aa12d4efeb7dd4ddfc723725ae22741a5241ea710/grpcio-1.82.1-cp311-cp311-win32.whl", hash = "sha256:e203d2e19d471630084a16c815616f8211dff21c268ab3c5f5bf38417832e074", size = 4256432, upload-time = "2026-07-08T12:34:50.432Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6a/2872c761b025d9ec74386f22a4a7d59c5a5b00ebf718761b33739ffc45de/grpcio-1.82.1-cp311-cp311-win_amd64.whl", hash = "sha256:0d8299c285fe6cc6a1f56badf8d3bc5078c8d20273ee64bafa3783b4bc29a769", size = 5009633, upload-time = "2026-07-08T12:34:52.67Z" }, + { url = "https://files.pythonhosted.org/packages/dc/88/d1350bf3343a2ed87d801584e40609f6c6bd3087926eeca03de50348cf4a/grpcio-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:c09bd5fa0d5b1fbd773ec349fe61441c3e4ebf168c229aa7538a820bdfad6a58", size = 6144689, upload-time = "2026-07-08T12:34:55.567Z" }, + { url = "https://files.pythonhosted.org/packages/e6/33/71875cdecd27c24ac1385d4783a09853f01b84a825a36aec2a2bc7d0d080/grpcio-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1eae24810720734598e3e6a1a528d5de0f265fe3fc86575e9ecce424b9ec7379", size = 11952034, upload-time = "2026-07-08T12:34:58.128Z" }, + { url = "https://files.pythonhosted.org/packages/82/b2/d9125df3d8a140dec12cc82c05b7deafedeababcff6496f28b2fd5634d10/grpcio-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6bd5daf5bde7b24d7ad2cbaf8bf9eac620d96222016bb5e7ddde930dec0673f", size = 6710772, upload-time = "2026-07-08T12:35:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/88/9b/69e2d1627398b964f34437dc476a5aff5a2cc8e7f247d26272b5674b5faf/grpcio-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecfde669cb687ac020d31ff76debe5dc7a62213335f02262eb6625628da1c03", size = 7450677, upload-time = "2026-07-08T12:35:03.926Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e7/8f855ca29c294956122a2a73023655b9b02602d5111dad2b9b00e7631c68/grpcio-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:011c8badee95734dee8bf05ce3464756a0ac3ebb8d443afd20c0e2b5e4640ad9", size = 6886855, upload-time = "2026-07-08T12:35:06.174Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f0/fa87e85f49925f44c479d07e58b051e69bcfef6b6d5fbc6749d140f6730a/grpcio-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b85f4564926fb23114d239392bdcae200db1e6179629edd7d7ab0ab89c96a197", size = 7501323, upload-time = "2026-07-08T12:35:08.49Z" }, + { url = "https://files.pythonhosted.org/packages/67/55/2e0b10ae1d3ef9dcc480b91dc2158f4931fc4675d3af0a2836e39b2a744f/grpcio-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2c0c8270833395644c3fe6b6a806397955a2bc0538000a19a78b90c05a6c16e0", size = 8536899, upload-time = "2026-07-08T12:35:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/a3d8b0431fa221efc51ee39d73595ede74ba82a43b7c4313192e580face2/grpcio-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ba199205ff46c7778290fe1673c91ac8e7e45678dd5c86e9e56fa33ec8788f6", size = 7913892, upload-time = "2026-07-08T12:35:13.944Z" }, + { url = "https://files.pythonhosted.org/packages/b8/92/f2651ec704d9852a56faef394775038afba435b50ce82ab2404d119c3355/grpcio-1.82.1-cp312-cp312-win32.whl", hash = "sha256:06127691866e295c14e84a1fb86356dd962254f6abd0da4ca4b001eea9e89438", size = 4240985, upload-time = "2026-07-08T12:35:16.048Z" }, + { url = "https://files.pythonhosted.org/packages/96/4f/a5fe8bf0d0a1b24855f370293075c931f27de4eb55f0f158786095bf3c11/grpcio-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:1fa3223a3a2e1db74f4c2b255189eb7ea875dfba56e221d252ee3fc7b204778e", size = 5001580, upload-time = "2026-07-08T12:35:18.689Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1", size = 6146068, upload-time = "2026-07-08T12:35:21.365Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc", size = 11948600, upload-time = "2026-07-08T12:35:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6", size = 6714591, upload-time = "2026-07-08T12:35:27.027Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f", size = 7454995, upload-time = "2026-07-08T12:35:29.599Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7", size = 6888621, upload-time = "2026-07-08T12:35:32.38Z" }, + { url = "https://files.pythonhosted.org/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095", size = 7505069, upload-time = "2026-07-08T12:35:35.072Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580", size = 8535384, upload-time = "2026-07-08T12:35:37.954Z" }, + { url = "https://files.pythonhosted.org/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf", size = 7910707, upload-time = "2026-07-08T12:35:40.797Z" }, + { url = "https://files.pythonhosted.org/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a", size = 4240363, upload-time = "2026-07-08T12:35:43.298Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90", size = 5000980, upload-time = "2026-07-08T12:35:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cb/cf9ae9e164c6e6dc8a494faa9771763df9da150eefe19671009624d1559f/grpcio-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2", size = 6146901, upload-time = "2026-07-08T12:35:49.261Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/eccf26dbcfb7f7cab8027c5490a16c8937c5aa7a2ec20a3eab2cf7a43165/grpcio-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0", size = 11954756, upload-time = "2026-07-08T12:35:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/75/3b3b4a3cc9f084b026af96e1d3e539b1af29ec7f41ed0dfff3cb99cc8626/grpcio-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c", size = 6723087, upload-time = "2026-07-08T12:35:54.973Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/b0f0c9b1400a99a4da4c09b114f101b192f8f11192e76f620b8962f5d90b/grpcio-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a", size = 7454542, upload-time = "2026-07-08T12:35:57.586Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bd/428e38868382aa193697a5aa53973f29c58e58ba4268aa0c86a2715ee58b/grpcio-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae", size = 6889588, upload-time = "2026-07-08T12:36:00.012Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/03e01d5e10259bf5c08ee50570cc94724e79c956f61fd2f09b341af0956c/grpcio-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98", size = 7514166, upload-time = "2026-07-08T12:36:02.693Z" }, + { url = "https://files.pythonhosted.org/packages/ff/59/278b4b600329e2ba3849f3c1ea3c820b3a01b38a7ad184ba09595e8d2733/grpcio-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27", size = 8536166, upload-time = "2026-07-08T12:36:05.349Z" }, + { url = "https://files.pythonhosted.org/packages/44/27/7ccf2ef00f27a8e47a79d641c8ceaf7d3028c7a03d9a97b4c8a9a783c086/grpcio-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e", size = 7912572, upload-time = "2026-07-08T12:36:08.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/be/33742482d2753f2d3a1b7641664b6622262d44f2f3b609f13425dd86d36f/grpcio-1.82.1-cp314-cp314-win32.whl", hash = "sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a", size = 4321856, upload-time = "2026-07-08T12:36:10.899Z" }, + { url = "https://files.pythonhosted.org/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5", size = 5141114, upload-time = "2026-07-08T12:36:13.595Z" }, ] [[package]] @@ -1244,15 +1245,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.6.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/db/2ad49878b36af4cff7527c1158b083ad6d9350462f1a35685cc3ebfa7c2b/httpcore2-2.6.0.tar.gz", hash = "sha256:95b692b582402ec49b3d84c2343556e4ac4c0962c8b3d39c48d485b9ecc240ab", size = 65592, upload-time = "2026-07-14T10:48:33.816Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/fa/08f483851a70ef10806e3b84240f2a8f923658035b794f7609795895d9ea/httpcore2-2.6.0-py3-none-any.whl", hash = "sha256:c237a45c7eef885cf032cb9b850d59fcf1fa7e00230307f08aab26486a6ed584", size = 81507, upload-time = "2026-07-14T10:48:31.504Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" }, ] [[package]] @@ -1272,7 +1273,7 @@ wheels = [ [[package]] name = "httpx2" -version = "2.6.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1281,9 +1282,9 @@ dependencies = [ { name = "truststore" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/17/1e142bf3c76684232a092e1e4002be07fd3403b1c2dcb15d0012ea300c8f/httpx2-2.6.0.tar.gz", hash = "sha256:5d362fd59562cf2139a60c67bb016587a70b36156a517f176c7cbf1587d1ab22", size = 92736, upload-time = "2026-07-14T10:48:35.12Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/86/7d82f7c6aac32433eaf0c914b8bc870ae25759413b9d7d52ea6aa15f2546/httpx2-2.6.0-py3-none-any.whl", hash = "sha256:6cccc3665d6bceb3c1c4f1422ae7e53fda67a853f0135f09b25ce0d4dcac01e3", size = 88541, upload-time = "2026-07-14T10:48:32.681Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" }, ] [[package]] @@ -1297,14 +1298,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.7.1" +version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, ] [[package]] @@ -1318,7 +1319,7 @@ wheels = [ [[package]] name = "inline-snapshot" -version = "0.32.5" +version = "0.35.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asttokens" }, @@ -1328,9 +1329,9 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/87/62b78b49042c533038ab1bf0931a7b70fdb78d07a11c9bf159be04027df8/inline_snapshot-0.32.5.tar.gz", hash = "sha256:5025074eab5c82a88504975e2655beeb5e96fd57ed2d9ebb38538473748f2065", size = 2626796, upload-time = "2026-03-13T18:35:54.891Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/38a1b9c6323c5dff0ab6c74aa839f1c7d8b7057a861d80ec5bfe834be24e/inline_snapshot-0.35.2.tar.gz", hash = "sha256:6cfd2ea0b52d9cb9beb13c1e73d740ff86f630ba48d9ea6947e54e3ff8494876", size = 2534713, upload-time = "2026-07-16T10:51:58.826Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/d3/73426dd3da75095fd071ce5c1f8e520e879a582ca04df861575c6feb9166/inline_snapshot-0.32.5-py3-none-any.whl", hash = "sha256:ac617c273e811ed5ca15abd8f8dbd3fa268296bb0642ccb1403a5df61ce2e39e", size = 84993, upload-time = "2026-03-13T18:35:52.955Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ea/de85fc264e4c76bda3552eee42904e8b7079fd826902aa5fa044bc5bbc3c/inline_snapshot-0.35.2-py3-none-any.whl", hash = "sha256:91b230b310c95a8c3b91cdbc637e064c7c2f98c13935b64875a7ba9ef174e161", size = 94669, upload-time = "2026-07-16T10:51:57.457Z" }, ] [package.optional-dependencies] @@ -1365,53 +1366,30 @@ wheels = [ [[package]] name = "ipython" -version = "9.10.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.11.*'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version == '3.11.*'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version == '3.11.*'" }, - { name = "jedi", marker = "python_full_version == '3.11.*'" }, - { name = "matplotlib-inline", marker = "python_full_version == '3.11.*'" }, - { name = "pexpect", marker = "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version == '3.11.*'" }, - { name = "pygments", marker = "python_full_version == '3.11.*'" }, - { name = "stack-data", marker = "python_full_version == '3.11.*'" }, - { name = "traitlets", marker = "python_full_version == '3.11.*'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c5/25/daae0e764047b0a2480c7bbb25d48f4f509b5818636562eeac145d06dfee/ipython-9.10.1.tar.gz", hash = "sha256:e170e9b2a44312484415bdb750492699bf329233b03f2557a9692cce6466ada4", size = 4426663, upload-time = "2026-03-27T09:53:26.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/09/ba70f8d662d5671687da55ad2cc0064cf795b15e1eea70907532202e7c97/ipython-9.10.1-py3-none-any.whl", hash = "sha256:82d18ae9fb9164ded080c71ef92a182ee35ee7db2395f67616034bebb020a232", size = 622827, upload-time = "2026-03-27T09:53:24.566Z" }, -] - -[[package]] -name = "ipython" -version = "9.12.0" +version = "9.15.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.12'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" }, - { name = "jedi", marker = "python_full_version >= '3.12'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" }, - { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, - { name = "stack-data", marker = "python_full_version >= '3.12'" }, - { name = "traitlets", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" }, + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, ] [[package]] @@ -1452,26 +1430,26 @@ wheels = [ [[package]] name = "jaraco-functools" -version = "4.4.0" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, ] [[package]] name = "jedi" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, ] [[package]] @@ -1485,111 +1463,113 @@ wheels = [ [[package]] name = "jiter" -version = "0.13.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" }, - { url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" }, - { url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" }, - { url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" }, - { url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" }, - { url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" }, - { url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" }, - { url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, - { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, - { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, - { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, - { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, - { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, - { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, - { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, - { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, - { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, - { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, - { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, - { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, - { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, - { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, - { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, - { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, - { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, - { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, - { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, - { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, - { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, - { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, - { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, - { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, - { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, - { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, - { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, - { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, - { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, - { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, - { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, - { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, - { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, - { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, - { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, - { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, - { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, - { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, - { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, - { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, - { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, - { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, - { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, - { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, - { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, - { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, - { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, - { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, - { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, - { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, - { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, - { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, - { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, + { url = "https://files.pythonhosted.org/packages/76/d8/b959609e44012a42b1f3e5ba98ea3b33c7e41e6d4b77cd8f00fd19b1d3ad/jiter-0.16.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c5fc4f8def331036a7b8e981b4347ebe409981edbc8308a5ea842b8c3614fa6c", size = 310082, upload-time = "2026-06-29T13:02:31.356Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3d/4d7f5667ea0e0548534ba880b84bb3d12924fd133aa83ad6c6c80fca3d76/jiter-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b", size = 315643, upload-time = "2026-06-29T13:02:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/9b/83/bed2dcb5c9f3e1ccfcbc67dda48265fe7d5ad0c9cadda5fe95f6e3b87f94/jiter-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:741eed508c233a76313a1c7b001f8f21b82f14327e9196ae8bd29a2cc164ae84", size = 341363, upload-time = "2026-06-29T13:02:34.853Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2f/6bb3c3dda668ebc0445689c81a2b0f26a82b10843d67ed9c9b2c3edc177f/jiter-0.16.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fb7bc819187b56dc48aa5c833aaf92257da8e07efdb9306156667bd2eeb491c", size = 365483, upload-time = "2026-06-29T13:02:36.295Z" }, + { url = "https://files.pythonhosted.org/packages/92/35/8a045ccb39164e70dcdae696413b661771f148b68b12b175c3a04d901937/jiter-0.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9610fd25ebccb43fca584136f5c2fbb26802447eccd430dfdbab95a0fd5126", size = 461219, upload-time = "2026-06-29T13:02:38.116Z" }, + { url = "https://files.pythonhosted.org/packages/e7/99/22292dbbf0ed0c610cfe5ddc7f3bd67237a412f121318f865196e62a07bd/jiter-0.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a1d68ff7ca1d3b5dee20a97a3decda7d5f15003823bf6d140c81f8561d3bc5c", size = 374905, upload-time = "2026-06-29T13:02:40.357Z" }, + { url = "https://files.pythonhosted.org/packages/29/ac/2f55ccb1f0eeafa6d89d24caf52f6f0944a59290ee199e9ade62177dca42/jiter-0.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de", size = 348320, upload-time = "2026-06-29T13:02:41.923Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/7d88b9174c40064fabc07c84a9b62e6b10f5644562ec0e0a29392edbe978/jiter-0.16.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:8fc4d94713c4697347e38faf7d6ef91547c142219bdcfc7220c4870879974244", size = 356519, upload-time = "2026-06-29T13:02:43.436Z" }, + { url = "https://files.pythonhosted.org/packages/27/57/c4a33aeef513a9d5e26e31534e0bcc752d6ea0e54c94ddb7b68bade669c2/jiter-0.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0f05e229edb29e68cdd0ccb83cea13b64263416120cf943767a6fd72e6787f", size = 394204, upload-time = "2026-06-29T13:02:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/9d/70/c6c23e76ebb3766b111bc399437bbc9f870a76e2a92e10b2a5f561d57372/jiter-0.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c842cbf374a8daf50b2c04212995bee34ca2ac2cdc29a901b4cdb072c9c4131", size = 521477, upload-time = "2026-06-29T13:02:46.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d3/0001c8c0c5976af2625bb1cfb1895e8ec693b6589fe4574b8e6fc2c85501/jiter-0.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5ed466aee31294d7cdcd4d37dfe5c42c97bc29d9a5f00eacf24504358309cb9b", size = 552187, upload-time = "2026-06-29T13:02:48.144Z" }, + { url = "https://files.pythonhosted.org/packages/f6/76/311b718e07e85740e48619c0632b36f7e0b8d113984499e436452ed13a9a/jiter-0.16.0-cp310-cp310-win32.whl", hash = "sha256:b42e9ff5376819c053da25809a8d4b6fa6e473b4856ebe42e298ac958be3d7f9", size = 206513, upload-time = "2026-06-29T13:02:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/db/7f/ac680eeb0777dc0eb7dc824800ba27880d7f6bc712e362d34ad8ee559f36/jiter-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26", size = 199505, upload-time = "2026-06-29T13:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" }, + { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" }, + { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" }, + { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" }, + { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, ] [[package]] name = "joserfc" -version = "1.6.8" +version = "1.7.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027, upload-time = "2026-07-19T15:43:02.739Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000, upload-time = "2026-07-19T15:43:01.299Z" }, ] [[package]] @@ -1609,7 +1589,8 @@ dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, - { name = "rpds-py" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -1618,16 +1599,17 @@ wheels = [ [[package]] name = "jsonschema-path" -version = "0.4.5" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "attrs" }, { name = "pathable" }, { name = "pyyaml" }, { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/7e6102f2b8bdc6705a9eb5294f8f6f9ccd3a8420e8e8e19671d1dd773251/jsonschema_path-0.4.5.tar.gz", hash = "sha256:c6cd7d577ae290c7defd4f4029e86fdb248ca1bd41a07557795b3c95e5144918", size = 15113, upload-time = "2026-03-03T09:56:46.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/d5/4e96c44f6c1ea3d812cf5391d81a4f5abaa540abf8d04ecd7f66e0ed11df/jsonschema_path-0.4.5-py3-none-any.whl", hash = "sha256:7d77a2c3f3ec569a40efe5c5f942c44c1af2a6f96fe0866794c9ef5b8f87fd65", size = 19368, upload-time = "2026-03-03T09:56:45.39Z" }, + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, ] [[package]] @@ -1677,31 +1659,31 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] name = "matplotlib-inline" -version = "0.2.1" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] [[package]] name = "mcp" -version = "2.0.0b2" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1710,7 +1692,6 @@ dependencies = [ { name = "mcp-types" }, { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -1720,22 +1701,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/aa/c5d38e0199304494be6370667d04d93d8681d9bdc864d56678250dbd3f3b/mcp-2.0.0b2.tar.gz", hash = "sha256:0528d0d38ae798fbff251616ec687faaaa8f5309571e0b2bc553c530fa10b8b1", size = 1590650, upload-time = "2026-07-14T16:47:57.897Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/90/187d6283a304acc6954987992ef17e2515739a649f148dc8b67b302e1bbd/mcp-2.0.0b2-py3-none-any.whl", hash = "sha256:9c50ae5afa08960ab76d50aa3adab3184952d9bea7ef87f4a4a5ba68bdefcf0a", size = 334286, upload-time = "2026-07-14T16:47:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, ] [[package]] name = "mcp-types" -version = "2.0.0b2" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/21/db529130ac8edd1d844fc322862afeceaf2b7f610a591fa528002b022e07/mcp_types-2.0.0b2.tar.gz", hash = "sha256:094fa7160106819ab39a1586179c3a9f070bfd833d0a6f8fcb30a54a986cc402", size = 65877, upload-time = "2026-07-14T16:47:59.198Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/e1/c466ceacdaa929396d35ad45176398acd0c416fead0970d3ad22618a19d7/mcp_types-2.0.0b2-py3-none-any.whl", hash = "sha256:35c9c33abb90a77dc6ad1daecaa6407c788c2f32d14d52dad8f843c4f008eae2", size = 68944, upload-time = "2026-07-14T16:47:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, ] [[package]] @@ -1749,11 +1730,11 @@ wheels = [ [[package]] name = "more-itertools" -version = "10.8.0" +version = "11.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] [[package]] @@ -1793,7 +1774,7 @@ wheels = [ [[package]] name = "openai" -version = "2.30.0" +version = "2.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1805,9 +1786,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/ac/f725c4efbda8657d02be684607e5a2e5ce362e4790fdbcbdfb7c15018647/openai-2.46.0.tar.gz", hash = "sha256:0421e0735ac41451cad894af4cddf0435bfbf8cbc538ac0e15b3c062f2ddc06a", size = 1114628, upload-time = "2026-07-17T02:48:06.05Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/206238ebcb50b235942b1c66dba4974776f2057402a8d91c399be587d66a/openai-2.46.0-py3-none-any.whl", hash = "sha256:672381db55efb3a1e2610f29304c130cccdd0b319bace4d492b2443cb64c1e7c", size = 1637556, upload-time = "2026-07-17T02:48:03.695Z" }, ] [[package]] @@ -1824,32 +1805,31 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.40.0" +version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.40.0" +version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.40.0" +version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -1860,84 +1840,84 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/b9e60435cfcc7590fa87436edad6822240dddbc184643a2a005301cc31f4/opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740", size = 25759, upload-time = "2026-03-04T14:17:24.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/6f/7ee0980afcbdcd2d40362da16f7f9796bd083bf7f0b8e038abfbc0300f5d/opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52", size = 20304, upload-time = "2026-03-04T14:17:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e", size = 19624, upload-time = "2026-07-16T15:25:19.096Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.40.0" +version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.40.0" +version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.61b0" +version = "0.65b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, ] [[package]] name = "packaging" -version = "26.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "parso" -version = "0.8.6" +version = "0.8.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, ] [[package]] name = "pathable" -version = "0.5.0" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, ] [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -1967,11 +1947,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.4" +version = "4.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, ] [[package]] @@ -1985,49 +1965,49 @@ wheels = [ [[package]] name = "prefab-ui" -version = "0.18.0" +version = "0.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cyclopts" }, { name = "pydantic" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/a3/25fe72b9887d9c2daa0ec5e79a7971a67aad31a6f71d634e23da662343ad/prefab_ui-0.18.0.tar.gz", hash = "sha256:f72e241f52f4720baac670f8527c773e1c1f4b558bce4f77097441eecbb51b9e", size = 3998186, upload-time = "2026-03-30T01:13:33.419Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/99/4e61eb3d3f8b09bdaa28bda3c99971264555f486a485333cb8e6f56c7d8c/prefab_ui-0.20.2.tar.gz", hash = "sha256:4ac17ebf8ec1c5a918a188625837fc608157907430a59c4feb8adac80e01262b", size = 4118979, upload-time = "2026-06-03T02:13:49.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/dd/28be02a264c59d64086122c8b0f9fa99fc52e040682358e5e08219846961/prefab_ui-0.18.0-py3-none-any.whl", hash = "sha256:c9d01bd423b0d5bf103d9a0e6cfac135bd973d416297c32a5bbccc182161cace", size = 1824803, upload-time = "2026-03-30T01:13:31.243Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d0/59b697dd5a44e632fcc4aba42ff5f88224df672ffea1f5e9a5a9e9e50698/prefab_ui-0.20.2-py3-none-any.whl", hash = "sha256:861d4914e4d9120996b4d5c6753788beeca433754d7bb3cfbd13f9dba7ea8e85", size = 1852274, upload-time = "2026-06-03T02:13:47.539Z" }, ] [[package]] name = "prek" -version = "0.3.8" +version = "0.4.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/62/ee/03e8180e3fda9de25b6480bd15cc2bde40d573868d50648b0e527b35562f/prek-0.3.8.tar.gz", hash = "sha256:434a214256516f187a3ab15f869d950243be66b94ad47987ee4281b69643a2d9", size = 400224, upload-time = "2026-03-23T08:23:35.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/54/edc21e275f9fa3540d4d98cf349c2de11621d6729cc401bb7aedf563609e/prek-0.4.10.tar.gz", hash = "sha256:db3122f4e780eb4587635e6a83df881caf2dbb1eb7799d1cca51158216d6f33b", size = 502565, upload-time = "2026-07-16T10:13:00.788Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/84/40d2ddf362d12c4cd4a25a8c89a862edf87cdfbf1422aa41aac8e315d409/prek-0.3.8-py3-none-linux_armv6l.whl", hash = "sha256:6fb646ada60658fa6dd7771b2e0fb097f005151be222f869dada3eb26d79ed33", size = 5226646, upload-time = "2026-03-23T08:23:18.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/52/7308a033fa43b7e8e188797bd2b3b017c0f0adda70fa7af575b1f43ea888/prek-0.3.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3d7fdadb15efc19c09953c7a33cf2061a70f367d1e1957358d3ad5cc49d0616", size = 5620104, upload-time = "2026-03-23T08:23:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b1/f106ac000a91511a9cd80169868daf2f5b693480ef5232cec5517a38a512/prek-0.3.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:72728c3295e79ca443f8c1ec037d2a5b914ec73a358f69cf1bc1964511876bf8", size = 5199867, upload-time = "2026-03-23T08:23:38.066Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e9/970713f4b019f69de9844e1bab37b8ddb67558e410916f4eb5869a696165/prek-0.3.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:48efc28f2f53b5b8087efca9daaed91572d62df97d5f24a1c7a087fecb5017de", size = 5441801, upload-time = "2026-03-23T08:23:32.617Z" }, - { url = "https://files.pythonhosted.org/packages/12/a4/7ef44032b181753e19452ec3b09abb3a32607cf6b0a0508f0604becaaf2b/prek-0.3.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6ca9d63bacbc448a5c18e955c78d3ac5176c3a17c3baacdd949b1a623e08a36", size = 5155107, upload-time = "2026-03-23T08:23:31.021Z" }, - { url = "https://files.pythonhosted.org/packages/bd/77/4d9c8985dbba84149760785dfe07093ea1e29d710257dfb7c89615e2234c/prek-0.3.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1000f7029696b4fe712fb1fefd4c55b9c4de72b65509c8e50296370a06f9dc3f", size = 5566541, upload-time = "2026-03-23T08:23:45.694Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1a/81e6769ac1f7f8346d09ce2ab0b47cf06466acd9ff72e87e5d1f0d98cd32/prek-0.3.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ff0bed0e2c1286522987d982168a86cbbd0d069d840506a46c9fda983515517", size = 6552991, upload-time = "2026-03-23T08:23:21.958Z" }, - { url = "https://files.pythonhosted.org/packages/6f/fa/ce2df0dd2dc75a9437a52463239d0782998943d7b04e191fb89b83016c34/prek-0.3.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fb087ac0ffda3ac65bbbae9a38326a7fd27ee007bb4a94323ce1eb539d8bbec", size = 5832972, upload-time = "2026-03-23T08:23:20.258Z" }, - { url = "https://files.pythonhosted.org/packages/18/6b/9d4269df9073216d296244595a21c253b6475dfc9076c0bd2906be7a436c/prek-0.3.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e1e5e206ff7b31bd079cce525daddc96cd6bc544d20dc128921ad92f7a4c85d", size = 5448371, upload-time = "2026-03-23T08:23:41.835Z" }, - { url = "https://files.pythonhosted.org/packages/60/1d/1e4d8a78abefa5b9d086e5a9f1638a74b5e540eec8a648d9946707701f29/prek-0.3.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dcea3fe23832a4481bccb7c45f55650cb233be7c805602e788bb7dba60f2d861", size = 5270546, upload-time = "2026-03-23T08:23:24.231Z" }, - { url = "https://files.pythonhosted.org/packages/77/07/34f36551a6319ae36e272bea63a42f59d41d2d47ab0d5fb00eb7b4e88e87/prek-0.3.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:4d25e647e9682f6818ab5c31e7a4b842993c14782a6ffcd128d22b784e0d677f", size = 5124032, upload-time = "2026-03-23T08:23:26.368Z" }, - { url = "https://files.pythonhosted.org/packages/e3/01/6d544009bb655e709993411796af77339f439526db4f3b3509c583ad8eb9/prek-0.3.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de528b82935e33074815acff3c7c86026754d1212136295bc88fe9c43b4231d5", size = 5432245, upload-time = "2026-03-23T08:23:47.877Z" }, - { url = "https://files.pythonhosted.org/packages/54/96/1237ee269e9bfa283ffadbcba1f401f48a47aed2b2563eb1002740d6079d/prek-0.3.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6d660f1c25a126e6d9f682fe61449441226514f412a4469f5d71f8f8cad56db2", size = 5950550, upload-time = "2026-03-23T08:23:43.8Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6b/a574411459049bc691047c9912f375deda10c44a707b6ce98df2b658f0b3/prek-0.3.8-py3-none-win32.whl", hash = "sha256:b0c291c577615d9f8450421dff0b32bfd77a6b0d223ee4115a1f820cb636fdf1", size = 4949501, upload-time = "2026-03-23T08:23:16.338Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b4/46b59fe49f635acd9f6530778ce577f9d8b49452835726a5311ffc902c67/prek-0.3.8-py3-none-win_amd64.whl", hash = "sha256:bc147fdbdd4ec33fc7a987b893ecb69b1413ac100d95c9889a70f3fd58c73d06", size = 5346551, upload-time = "2026-03-23T08:23:34.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/05/9cca1708bb8c65264124eb4b04251e0f65ce5bfc707080bb6b492d5a0df7/prek-0.3.8-py3-none-win_arm64.whl", hash = "sha256:a2614647aeafa817a5802ccb9561e92eedc20dcf840639a1b00826e2c2442515", size = 5190872, upload-time = "2026-03-23T08:23:29.463Z" }, + { url = "https://files.pythonhosted.org/packages/db/e7/5a63528ba7b95b64f38db3e253aed49ee8e5e8ba16589889d2b7f809edb7/prek-0.4.10-py3-none-linux_armv6l.whl", hash = "sha256:023f302741d79301346c3088ba43a9592aff0ecdbe5ddc3019fa9b1183319c5e", size = 5694609, upload-time = "2026-07-16T10:12:26.352Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ef/ee9e6bf9a5ce242e9e4e66ac4e2e9042a0f6fd9f367cee18ad404456e93d/prek-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:72adc707e16f97564bbae08d22b222ac3bb2491f8fbfb5a0754f80d472c28a71", size = 6044037, upload-time = "2026-07-16T10:12:28.539Z" }, + { url = "https://files.pythonhosted.org/packages/68/7e/da08cc39e5348ccb9234e63a21ee56861f72e8497d6a78f0db1ccae6515d/prek-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:04c9321957e1b32e1fc7cf60bb4f90bba3761f8659d5551ed04f96e25596de49", size = 5535983, upload-time = "2026-07-16T10:12:30.691Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/0486a35bb687a9beac7a5810bd1104c6da56d469b30b1eeaeefd03c99da2/prek-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e66ccf6c5e4ebadd05cd98cb338d7f553e4d27aa243cf91279c5a569b3cdccc7", size = 5862085, upload-time = "2026-07-16T10:12:33.042Z" }, + { url = "https://files.pythonhosted.org/packages/52/39/277fe17ae1f121e532e3942456f5a6d01ddacfbc550e481dcb359be7a1b0/prek-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:63f9061d75a50ef0ca92c4b596ad352937a845df80758244950e513b27e9e18f", size = 5605697, upload-time = "2026-07-16T10:12:35.498Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/08354af3e000f2656fad086690d834eab6c04631ff41313a219ea6232199/prek-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c2ff7110e4bfaafbbab13c2893a337081aca61ed797f14b6b224d2ea9741eef", size = 6034111, upload-time = "2026-07-16T10:12:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/4702396c8d486132e5ce009ab56a0b37f50cb6866830d371f2617b7bdfdc/prek-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b696a05542e79aa27bcce68d1792e77f4fe6f9c6b012b34d74d62f964f3c72d", size = 6787203, upload-time = "2026-07-16T10:12:40.031Z" }, + { url = "https://files.pythonhosted.org/packages/90/29/b5d5d6fb87ebd64b37471e3e79761de9983f85e14d69c522efe7af6620ce/prek-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:431b44d6054e72815b4b05e1173596dfd02a7f7461211d40a2e3117e414642ad", size = 6261333, upload-time = "2026-07-16T10:12:42.216Z" }, + { url = "https://files.pythonhosted.org/packages/94/d6/54ba696d19f7efdc184093353cce713a850aef9c3556e23faeecafa22e94/prek-0.4.10-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ccbd2b4fd1df790087ba18b4506f680471922a5f13714f19801568434a040dee", size = 5867761, upload-time = "2026-07-16T10:12:44.329Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/3975098aa2baaabfc10f99f9fcf78045c4f10851beed8e9812b6a2688eab/prek-0.4.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:479e7480b447191aa5c6ed67e80f081d0f5ee4e878b140f4d2cee44165395f1c", size = 5714412, upload-time = "2026-07-16T10:12:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/97/c0/3e0aac190fe95fdef98526343559b61d4d9fd54444c8c9137ba02412afe1/prek-0.4.10-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0bb7451025cbd2b68e480a13cf665d7a5c87c8b87bf18549a78985c17df817ed", size = 5578145, upload-time = "2026-07-16T10:12:48.261Z" }, + { url = "https://files.pythonhosted.org/packages/d7/44/7b26035534204b8b8a9d5e625479201e616413d287262f557cb32e1f8d77/prek-0.4.10-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4fb047e5776676805794574b2d7b178cb3ab536793aadf172419fcda56b34a57", size = 5889245, upload-time = "2026-07-16T10:12:50.818Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6c/178a9d768876b4211a1bf63907fe308ae02d173639bcf41cea3c5eed35c1/prek-0.4.10-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:08318818d19caf79643babb89f872c92fda134a622b4731df1d6ed61e29d2d26", size = 6372849, upload-time = "2026-07-16T10:12:52.952Z" }, + { url = "https://files.pythonhosted.org/packages/4d/84/d5f5ac8193602883f9dd1d675d9d4084e34fbe3ed2ef50a0c336d8a53d8f/prek-0.4.10-py3-none-win32.whl", hash = "sha256:092872714dcde480a662bbdd98b980b248c2d3e10543d4d53a3a58cc9e5b35b0", size = 5413005, upload-time = "2026-07-16T10:12:55.113Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/9e648fda10bc02c9b6ba305f93b6a6e4fd37d23d13a269a9d2d6bb44eaa1/prek-0.4.10-py3-none-win_amd64.whl", hash = "sha256:3d323a18d0f8c50e474a8fa29fb93bd2db680116d8afb19b76e72ad4667f58e6", size = 5799075, upload-time = "2026-07-16T10:12:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/22/74/b34d8c80cec8dccc7b922c75b9dca62b18b603b5ed2eea93c9d7c2928d2d/prek-0.4.10-py3-none-win_arm64.whl", hash = "sha256:5e93865ef96756c4a26f37ece04ad514abbc19ae6a23ed1a507b6314e6a0d2fb", size = 5563955, upload-time = "2026-07-16T10:12:59.07Z" }, ] [[package]] name = "prometheus-client" -version = "0.24.1" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, ] [[package]] @@ -2044,17 +2024,17 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.6" +version = "7.35.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, - { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, - { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, - { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, ] [[package]] @@ -2105,20 +2085,21 @@ wheels = [ [[package]] name = "py-key-value-aio" -version = "0.4.4" +version = "0.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, + { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, ] [package.optional-dependencies] filetree = [ - { name = "aiofile" }, + { name = "aiofile", version = "3.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "aiofile", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "anyio" }, ] keyring = [ @@ -2133,11 +2114,11 @@ redis = [ [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] @@ -2163,7 +2144,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2171,9 +2152,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [package.optional-dependencies] @@ -2183,120 +2164,118 @@ email = [ [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, - { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, - { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, - { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, - { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, - { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, - { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, - { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, - { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, - { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -2386,7 +2365,7 @@ wheels = [ [[package]] name = "pydocket" -version = "0.20.0" +version = "0.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "burner-redis" }, @@ -2405,9 +2384,9 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "uncalled-for" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/9d/05d54dccfaa505c0bc2e480bc331c44552cdc16af3f44f5893c75293a165/pydocket-0.20.0.tar.gz", hash = "sha256:4b5132a5754ba54f894d46bf2cbdc12e237adada73bc76ca367017536098df7f", size = 361050, upload-time = "2026-05-04T00:27:34.393Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/bf/7f1134e990855f373e5ee6ba316db8fe654a2d7dd852b41ab890fcfb91e3/pydocket-0.20.1.tar.gz", hash = "sha256:d72b3784e4b5069b39e5f49f599d54a891e1b6222c27a8bcfbd4dee0f57d4895", size = 361993, upload-time = "2026-05-06T14:06:25.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/cb/635665c07be980ec48c92b830907e6796012801b107cb1166a213e49ec38/pydocket-0.20.0-py3-none-any.whl", hash = "sha256:1f745278be09d3526f1bdd579c2d92f77fa0a534a39b893e9ef21dfc2ee52378", size = 102483, upload-time = "2026-05-04T00:27:32.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9d/1bd873a0ea480dec388c40ac1a7500c129efbb9d61e2fef6b97236703458/pydocket-0.20.1-py3-none-any.whl", hash = "sha256:c886ece90ac93018f069d1eef9443f888404081d7258955e16847752575c95ae", size = 102774, upload-time = "2026-05-06T14:06:24.548Z" }, ] [[package]] @@ -2503,11 +2482,11 @@ wheels = [ [[package]] name = "pyreadline3" -version = "3.5.4" +version = "3.5.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" }, ] [[package]] @@ -2521,7 +2500,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2532,23 +2511,23 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] @@ -2567,16 +2546,16 @@ wheels = [ [[package]] name = "pytest-env" -version = "1.6.0" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "python-dotenv" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/69/4db1c30625af0621df8dbe73797b38b6d1b04e15d021dd5d26a6d297f78c/pytest_env-1.6.0.tar.gz", hash = "sha256:ac02d6fba16af54d61e311dd70a3c61024a4e966881ea844affc3c8f0bf207d3", size = 16163, upload-time = "2026-03-12T22:39:43.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/49/08ee056f9cc655e437abcf2ae399884844b623223476ae6a77244131db03/pytest_env-1.7.0.tar.gz", hash = "sha256:0c1dc1101fb8d3ab3611e8f8d657ba06c3c0c167fc85c90457e5b27f2508f43e", size = 16408, upload-time = "2026-07-21T13:09:21.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/16/ad52f56b96d851a2bcfdc1e754c3531341885bd7177a128c13ff2ca72ab4/pytest_env-1.6.0-py3-none-any.whl", hash = "sha256:1e7f8a62215e5885835daaed694de8657c908505b964ec8097a7ce77b403d9a3", size = 10400, upload-time = "2026-03-12T22:39:41.887Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fc/9f2975c41d41bf5bd9a7d0fc03085ec20052b456b079df53828ae4a1b100/pytest_env-1.7.0-py3-none-any.whl", hash = "sha256:9ee0f1fe859d23fcdb533fe2909a404b3b133d02674a56df275bbe4df4eb104b", size = 10263, upload-time = "2026-07-21T13:09:20.677Z" }, ] [[package]] @@ -2719,24 +2698,27 @@ wheels = [ [[package]] name = "pywin32" -version = "311" +version = "312" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, - { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, - { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, - { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, ] [[package]] @@ -2814,14 +2796,14 @@ wheels = [ [[package]] name = "redis" -version = "7.4.0" +version = "8.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, ] [[package]] @@ -2830,7 +2812,8 @@ version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "rpds-py" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } @@ -2840,7 +2823,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.0" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2848,41 +2831,44 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "rich" -version = "14.3.3" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "rich-rst" -version = "1.3.2" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils" }, + { name = "pygments" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, ] [[package]] name = "rpds-py" version = "0.30.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, @@ -3002,28 +2988,156 @@ wheels = [ ] [[package]] -name = "ruff" -version = "0.15.8" +name = "rpds-py" +version = "2026.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, - { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, - { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, - { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, - { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, - { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, - { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, - { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, - { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, - { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, ] [[package]] @@ -3059,15 +3173,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.3.4" +version = "3.4.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, ] [[package]] @@ -3175,23 +3289,23 @@ wheels = [ [[package]] name = "tqdm" -version = "4.67.3" +version = "4.69.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/69/40407dfc835517f058b603dbf37a6df094d8582b015a51eddc988febbcb7/tqdm-4.69.0.tar.gz", hash = "sha256:700c5e85dcd5f009dd6222588a29180a193a748247a5d855b4d67db93d79a53b", size = 792569, upload-time = "2026-07-17T18:09:06.2Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl", hash = "sha256:9979978912be667a6ef21fd5d8abf54e324e63d82f7f43c360792ebc2bc4e622", size = 676680, upload-time = "2026-07-17T18:09:04.172Z" }, ] [[package]] name = "traitlets" -version = "5.14.3" +version = "5.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, ] [[package]] @@ -3205,51 +3319,51 @@ wheels = [ [[package]] name = "ty" -version = "0.0.59" +version = "0.0.61" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/b0/84ae7b3bf6e3e9f57eb9635eeff5a80b36e57aa089f40be0fb5c384fa176/ty-0.0.59.tar.gz", hash = "sha256:53e53ffeed78ad59cd237fa8ea1316d2b94e13efdea9a945698acab549e005aa", size = 6145435, upload-time = "2026-07-12T20:22:02.781Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/63/6944925d0fe9a4bb9cc744e6c045a42bbd2ee4654c103190674577a36c3f/ty-0.0.61.tar.gz", hash = "sha256:acbf0d914cc7e2e57ccc440036af36114819e2a604a5ffb554e72e4ca7dd65a2", size = 6234957, upload-time = "2026-07-18T01:39:54.696Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/e8/650b42fbef4d48e6ca682b0b6e9b68fa8fcf55cbb0a6892ab89990018b6f/ty-0.0.59-py3-none-linux_armv6l.whl", hash = "sha256:f8fb08a767ef8f11ea3c537b9d77860726cc2bc39e6f77ad13c02d5b289f20a7", size = 11700328, upload-time = "2026-07-12T20:21:26.046Z" }, - { url = "https://files.pythonhosted.org/packages/22/ac/0ca3a89d5f59ae5f308e5e83428cac5f9143200767743e052fba90b4b81e/ty-0.0.59-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c7f4d5630836c8a0ba13dd4ac7bdae080a7d6ebe965b817ff642dc961bcf2a53", size = 11494310, upload-time = "2026-07-12T20:21:28.491Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f8/5076de6001cefbccd8e6dc8472262697e43308ff66b0e87c72abba136357/ty-0.0.59-py3-none-macosx_11_0_arm64.whl", hash = "sha256:872f6fb02c6db5553c4d5fb283b3d50f0985fb9a29a910e4fda4793a775c1926", size = 11026797, upload-time = "2026-07-12T20:21:30.879Z" }, - { url = "https://files.pythonhosted.org/packages/2e/0f/fca28481b6a138e2b798ad9fdc98a095475f9104948ba242fce4b477782b/ty-0.0.59-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2af8eefbfe806337770eec12c0c819c5f1b8f5b85f8369cb1cc9fa25234a2208", size = 11475304, upload-time = "2026-07-12T20:21:33.041Z" }, - { url = "https://files.pythonhosted.org/packages/08/4b/1fed8b81b389ef4bbc0400f19e05fc16496b162577779dc0e5fc65ac216c/ty-0.0.59-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0acf8b76a1c9a7ddef460b42475f6c76193164426ab080783af1c3175b4b999b", size = 11533131, upload-time = "2026-07-12T20:21:35.189Z" }, - { url = "https://files.pythonhosted.org/packages/5f/fc/04eec35e05a10e0fea1c6503a290ccc3935efda9c845aff64e83282c1af7/ty-0.0.59-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:043c2e00eb1d7475f928af7dedd71f69b64e69bfca55e36f4c968479e1373fc4", size = 12205932, upload-time = "2026-07-12T20:21:37.324Z" }, - { url = "https://files.pythonhosted.org/packages/a9/dd/a61de859659fa11b55917ad38340a8f2c61f5ae17d1874929f29084c6990/ty-0.0.59-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0d688d857441df57f48fca66c029d85cf737c510e7be1d01144cdad1e58d968", size = 12758406, upload-time = "2026-07-12T20:21:39.525Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e8/fa66f05997eab8ca75fc4f17320140e25467849e0cc75597f898cc22099c/ty-0.0.59-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a96c9f88394a3b42c737e2125b2330543f0d90a43b49761f377d96f8c3ee0d62", size = 12288176, upload-time = "2026-07-12T20:21:41.784Z" }, - { url = "https://files.pythonhosted.org/packages/15/68/0fca59963bd5123f42d5f7da50667e7a52e8e9615e3a16d8c2c0d3b2d143/ty-0.0.59-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f08dbcb268edcafcb152e59475b5b495ce28d0b340a395c09943557678f4d5a6", size = 12028471, upload-time = "2026-07-12T20:21:43.82Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5b/cd7dabbbab392578f11179919da5c25d8c3322e5388a688f539ea0539603/ty-0.0.59-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8812764b9a40fdc98df1272826e73a298ef56b06681135e643bcf90aad1896f7", size = 12297646, upload-time = "2026-07-12T20:21:45.76Z" }, - { url = "https://files.pythonhosted.org/packages/1d/37/2e9c94f0b383d8cbe1a35517ab470b7810bc9d7501603ab532bcd5be5e90/ty-0.0.59-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fd53b8581641d8dad7bfac6d5ea589e91a883d6837e0b9a286fdae30722b7c69", size = 11432519, upload-time = "2026-07-12T20:21:47.694Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0a/af93e9785200f11ac416cc20235fc2464c9bd978e791190684ea0e458795/ty-0.0.59-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:86da5872124a41877d95058bc17d33ddcff034b587eb5f1e2917ab88ba227dac", size = 11554993, upload-time = "2026-07-12T20:21:49.671Z" }, - { url = "https://files.pythonhosted.org/packages/4b/dd/651bf87e20d00376c81b19124756491cffaf20eb8bec05a8794e5a8cf641/ty-0.0.59-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a233eef5f2fd4d894881e4a0aec83c9f172bfae1d787d6596ee1939fcc7723e", size = 11818230, upload-time = "2026-07-12T20:21:51.659Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/c947c4155fea751d135b19affdf734bbce72a94e446b866cf0c62f8bed69/ty-0.0.59-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ff678c18b5f1e3128b75a35e50dee7908dea55155baa31cd790619d5014cbf5", size = 12135194, upload-time = "2026-07-12T20:21:53.796Z" }, - { url = "https://files.pythonhosted.org/packages/b1/13/e5feb138888de1e95037c843571bbbd4ac21bf0a190507468098599a321f/ty-0.0.59-py3-none-win32.whl", hash = "sha256:cf8abb4b8095c5fe39102b8127f5886db308c8d4600909ddbc905512ce9c8163", size = 11179249, upload-time = "2026-07-12T20:21:55.752Z" }, - { url = "https://files.pythonhosted.org/packages/76/dd/52914dcbeeba92c207de40ef7109a58dcb5527aeb21c8f8feb7402aa9e29/ty-0.0.59-py3-none-win_amd64.whl", hash = "sha256:1dde20a82243d24407869e5a608c2f15efddd5cefc662aef461a5af84bfb3f8b", size = 12251079, upload-time = "2026-07-12T20:21:58.1Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8f/ac36fde77e223297454c1e0aeb8888c169eaacf3163bb609e3af942c88cb/ty-0.0.59-py3-none-win_arm64.whl", hash = "sha256:987043ee9e021f49493d9135891ac69c1affeee0d4ad4480c5fa4d9c975fc91b", size = 11650921, upload-time = "2026-07-12T20:22:00.348Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cf/044f31523e2768e3e64b0ca2ec32f70b3a731d4a2caa6ea110baf26e251c/ty-0.0.61-py3-none-linux_armv6l.whl", hash = "sha256:148779b8675eac93f40ec58bd70037fe67537117f20a23272264f8f136d41336", size = 11891448, upload-time = "2026-07-18T01:39:18.449Z" }, + { url = "https://files.pythonhosted.org/packages/d2/55/558cfe76b65d91d1854bbfac336020bd42fd887caa632d845d13c0c539eb/ty-0.0.61-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:08217382b3385808ee7288501ea3214b32631b08d1fd091ece6799b0c95264c5", size = 11602442, upload-time = "2026-07-18T01:39:20.914Z" }, + { url = "https://files.pythonhosted.org/packages/27/be/78c0ae6634cd606a68e5b46b338db427a48a1800c96a749b2d2f7a702e03/ty-0.0.61-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d99c729011b47dec20e78a32ac9c8f6defd4cf62f7bb851bbccf70dde6cee50", size = 11125286, upload-time = "2026-07-18T01:39:22.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/a40793962f1b6337938ddb0bca7496b54e70879e23b4d2cc8dfd7e5d1af3/ty-0.0.61-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cda607978ae271b77e51c947663218bce635c3507e256865444b10c37cdb60d", size = 11663403, upload-time = "2026-07-18T01:39:25.017Z" }, + { url = "https://files.pythonhosted.org/packages/98/c1/7879244da5b30407dc368946d36be5024380073408b079f144ffe034030e/ty-0.0.61-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d78f160a0f9434d570cdcdbc4dafba1f6aac3c47a32f9f63995b3cb55ffe4b6", size = 11715250, upload-time = "2026-07-18T01:39:27.045Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/8a4637cd58abd37f315dd515e24c582986cb1bfdf2edc4786882f5a4f69a/ty-0.0.61-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09aeab4800b36e93e4ce918699004da642d74988cac920b7592a6a2b9be6611c", size = 12393876, upload-time = "2026-07-18T01:39:29.197Z" }, + { url = "https://files.pythonhosted.org/packages/27/4b/27e7c640b1272743503229aa17ae2167a538040c4716a2fa1777c2b34fea/ty-0.0.61-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dccc8136df44142a109953a168be17b4915c99876b047d0b6672c31dae939bdf", size = 12958187, upload-time = "2026-07-18T01:39:31.308Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f5/70eaaefb6081fb0a8115cff66fbfaa20dafac8c646df2477adad95a59de2/ty-0.0.61-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:220760c2d13a887d027ee1093172c24ac35b6e634805329c93a30908ae4d3f5c", size = 12560101, upload-time = "2026-07-18T01:39:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/17bae3b6429b5c479dc6c1e344d34e1f79efbc27531f15f3ee5b5da63745/ty-0.0.61-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:effefbb89da7128d18059529d1c2ea390fe7f1f3882690d257ca2143d49a0c34", size = 12225389, upload-time = "2026-07-18T01:39:35.436Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/2ac380ba20d6395542c8df1d6fa4f00e2aead784c2e6aaefa1e02ed0610c/ty-0.0.61-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba8b28a5ef811d5bb6461e37d76110c06fd20487474865c323d3d18b08b972b2", size = 12548403, upload-time = "2026-07-18T01:39:37.556Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/7da4b73e825e1a9808c26d68b0156e9a37aede1846191210dfffb8c64042/ty-0.0.61-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88ecd6d9b05e8174b1860dac9bd3e188d6cef5702b0d3239fd9f94f6ac73a29d", size = 11621813, upload-time = "2026-07-18T01:39:39.919Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/5b58015e998cd0d89b17a463b6321421457d86d987574e8dac65ddfceba3/ty-0.0.61-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb0cdfe4c48542ffb9a1139825dfa3d4aae49e96e966682ef7da762ab97831ff", size = 11734101, upload-time = "2026-07-18T01:39:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/a6/21/294f4cc819b7b12ed659fd860e5cdfbd592d4c768c8f23596685dbc43e6b/ty-0.0.61-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dff03873c0c3d0b44738f8b6d403b0756a31cf54c65136397df7624c6159b1f0", size = 11988401, upload-time = "2026-07-18T01:39:44.183Z" }, + { url = "https://files.pythonhosted.org/packages/2e/26/0f96f79fdac118521a9771e9eef3f9b3f447d647b2c77953e80a1715c7e8/ty-0.0.61-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a9210e80e3d41c1dfc751e9e8e0980272f475031fafd0fb0f48aee233c78da03", size = 12330624, upload-time = "2026-07-18T01:39:46.662Z" }, + { url = "https://files.pythonhosted.org/packages/e3/08/1e62d1bca5c0cebdc7a34db1f4b61557aab85961cedd56953dd2c32d3e66/ty-0.0.61-py3-none-win32.whl", hash = "sha256:e3e1fe06f49a5492a922a5df2739834aa5ee978c7dd10414119dc8755cc40c9c", size = 11313991, upload-time = "2026-07-18T01:39:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/26/f1/d8e33b3aeb36b73d81ae34d10e46ec4abf506d68f4e0a1491a76a593dd42/ty-0.0.61-py3-none-win_amd64.whl", hash = "sha256:25f2291169e0298fcdbba1b1fea64f8207a6c1908dddef32346fd5e3e6ac9221", size = 12311717, upload-time = "2026-07-18T01:39:50.881Z" }, + { url = "https://files.pythonhosted.org/packages/e1/14/7caec26d93a943c0e7d15eb7374644508d08cbd387d112b722b12d14e044/ty-0.0.61-py3-none-win_arm64.whl", hash = "sha256:3e496f7698bc4b5bbb1eb66d8b5799ba87596d88d36604ca359083893fa2fc49", size = 11693485, upload-time = "2026-07-18T01:39:52.73Z" }, ] [[package]] name = "typer" -version = "0.24.1" +version = "0.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -3266,20 +3380,20 @@ wheels = [ [[package]] name = "tzdata" -version = "2025.3" +version = "2026.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] [[package]] name = "uncalled-for" -version = "0.2.0" +version = "0.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488, upload-time = "2026-02-27T17:40:58.137Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" }, + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, ] [[package]] @@ -3293,203 +3407,265 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.42.0" +version = "0.51.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, ] [[package]] name = "watchfiles" -version = "1.1.1" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, - { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, - { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, - { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, - { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, - { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, - { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, - { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, - { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, - { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] [[package]] name = "wcwidth" -version = "0.6.0" +version = "0.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] [[package]] name = "websockets" -version = "16.0" +version = "16.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, - { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, - { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, - { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, - { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, - { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, + { url = "https://files.pythonhosted.org/packages/08/e7/d1671fb984f9dd844e1da5288070c7c23c9eaba3082d3871aae19c3ab8b9/websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d", size = 179570, upload-time = "2026-07-17T22:48:24.032Z" }, + { url = "https://files.pythonhosted.org/packages/99/f5/70df723bf571f5e0b1b845e0a4ff1c966eeb84f667599fc251caa37d15a3/websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731", size = 177252, upload-time = "2026-07-17T22:48:25.775Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/2f14b2e167170b8bf1c8bb7f9b0d78000f470d41a2085a91f33e3917b6c9/websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4", size = 177530, upload-time = "2026-07-17T22:48:27.337Z" }, + { url = "https://files.pythonhosted.org/packages/f3/18/a17e2f0cde02dc10154c808deed7e1d8528afff93612f70d3f0a5b19b011/websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb", size = 186038, upload-time = "2026-07-17T22:48:28.756Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b0/41de283899cf5929d637b72a508cdbc9aa40dc0f317c6b77613fd1000488/websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838", size = 187278, upload-time = "2026-07-17T22:48:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/50/61/874aab5257e027f9f61b5004cec65e592babca7942b1bc09f38e72b7f1fd/websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87", size = 189936, upload-time = "2026-07-17T22:48:31.896Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1a/42173913ac5519607220849ed417c864d77384e4119f06dbba964a50f096/websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3", size = 187796, upload-time = "2026-07-17T22:48:33.344Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f4/37c1840bd89b529479aec41470b97b7c683b107ca90b6399ac5afb99dedf/websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4", size = 186481, upload-time = "2026-07-17T22:48:34.843Z" }, + { url = "https://files.pythonhosted.org/packages/9e/70/652d9b964adcfbeb056f42e0ca6bece34d108fe75534e74df20643cae199/websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3", size = 184351, upload-time = "2026-07-17T22:48:36.307Z" }, + { url = "https://files.pythonhosted.org/packages/13/f1/af3850e5d48d482921985be72ebcb169c6180b3a77b57bd612deebcee23b/websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b", size = 186791, upload-time = "2026-07-17T22:48:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/1d/40/1a4e3ed4969ec378dcad337e5f1472c5e292cb3e733bc392f0dc2e230abd/websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d", size = 185413, upload-time = "2026-07-17T22:48:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3e/4e3fa1afe8f1a6a780434cd9ba8eb422632b044eff3dd73f6af67523c147/websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d", size = 187178, upload-time = "2026-07-17T22:48:40.676Z" }, + { url = "https://files.pythonhosted.org/packages/71/ab/dd742766aa5dda7f349be0de49e4d565b84cf6f7f7fa02e07692f0f2bdd9/websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165", size = 185051, upload-time = "2026-07-17T22:48:42.098Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f5/76438c6560f416f1c0a7f587679fb97cc6e99ed336011d43ce2002dd27c1/websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc", size = 185846, upload-time = "2026-07-17T22:48:43.472Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/5c0320f2127823d27b2d56d611d31b0b284ad4edcb41364d66bf4c92b537/websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a", size = 186066, upload-time = "2026-07-17T22:48:44.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/97/875986b857b955c3f9dd192cb8a1af81254dfb2ea22cc9590f0a1e020b8b/websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9", size = 179940, upload-time = "2026-07-17T22:48:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/54/82/1013a5fe7ddae8e102bc3b4b39db81d8d28fd02100a324ce6ede8cd832b1/websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f", size = 180239, upload-time = "2026-07-17T22:48:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, + { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, + { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, ] [[package]] name = "zipp" -version = "3.23.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ]