diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 2542ee68f..bcc2698dd 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -19,6 +19,13 @@ Be friendly and welcoming while maintaining high standards. Call out what works Even perfect code for unwanted features should be rejected. +### Dependency version compatibility + +When a PR adapts code to a new version of a dependency (e.g., removing a parameter that was dropped upstream, using a new API): +- **The version pin in `pyproject.toml` must match.** If the change breaks compatibility with the previously-pinned minimum version, the minimum version must be bumped. Otherwise users on the old version get a regression. +- **If backwards compatibility with the old version is desired**, the code must handle both versions (e.g., try/except, version check). Simply deleting the old API usage without bumping the pin is always wrong — it silently breaks users on the old version. +- **Lock file (`uv.lock`) changes should be scoped to the PR's purpose.** A PR fixing a ty compatibility issue should not also include unrelated dependency version bumps (anthropic, google-auth, etc.) from running `uv sync --upgrade`. These create noise and make the diff harder to review. + ### API design and naming Identify confusing patterns or non-idiomatic code: diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md new file mode 100644 index 000000000..da5f1ff3d --- /dev/null +++ b/.claude/skills/review-pr/SKILL.md @@ -0,0 +1,102 @@ +--- +name: review-pr +description: Monitor and respond to automated PR reviews (Codex bot). Use when pushing a PR, checking review status, or responding to bot feedback. Handles the full cycle of push -> wait for review -> evaluate comments -> fix -> re-push. +--- + +# PR Review Workflow + +This repo has `chatgpt-codex-connector[bot]` configured as an automated reviewer. After every push to a PR branch, Codex reviews the diff and either: +- Reacts with a thumbs-up on its review body (no suggestions — PR is clean) +- Posts inline comments with suggestions (each tagged with a priority badge) + +## Checking review status + +After pushing, check whether Codex has reviewed the latest commit: + +```bash +# Get the latest commit SHA on the branch +LATEST=$(git rev-parse HEAD) + +# Check if Codex has reviewed that specific commit +gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/reviews \ + | jq "[.[] | select(.user.login == \"chatgpt-codex-connector[bot]\" and .commit_id == \"$LATEST\")] | length" +``` + +If the count is 0, Codex hasn't reviewed the latest push yet. Wait and check again. + +If the count is > 0, check for inline comments on the latest review: + +```bash +# Get the review body to check for thumbs-up +gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/reviews \ + | jq '[.[] | select(.user.login == "chatgpt-codex-connector[bot]") | {state, body: .body[:300], commit_id: .commit_id}] | last' +``` + +A clean review from Codex looks like a review body that contains a thumbs-up reaction or says "no suggestions." If the body contains "Here are some automated review suggestions," there are inline comments to evaluate. + +## Evaluating Codex comments + +Fetch all inline comments from Codex: + +```bash +gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/comments \ + | jq '[.[] | select(.user.login == "chatgpt-codex-connector[bot]") | {body, path, line, created_at}]' +``` + +Codex comments include priority badges: +- `P0` (red) — Critical issue, likely a real bug +- `P1` (orange) — Important, worth fixing +- `P2` (yellow) — Moderate, evaluate on merit + +**How to evaluate Codex comments:** + +1. **Treat Codex as a competent but sometimes overzealous reviewer.** It catches real bugs (cache eviction ordering, silent data loss, missing validation) but also suggests scope expansions and hypothetical improvements. + +2. **Fix real bugs** — issues in code you actually changed where behavior is incorrect or data is silently lost. + +3. **Dismiss scope expansion** — if a comment points out a pre-existing limitation unrelated to your diff, note it as a potential follow-up but don't block the PR. + +4. **Dismiss speculative concerns** — if a comment describes a scenario that requires very specific conditions and the existing behavior is acceptable, dismiss it. + +5. **When fixing, be proactive** — if Codex found one instance of a pattern bug (e.g., missing role validation in one handler), check all similar code paths before pushing. Codex will find the next instance on the next review cycle, so get ahead of it. + +## Responding to every comment + +**Every Codex comment must get a visible response** — either a fix or a reply explaining why it was dismissed. The maintainer can't see your reasoning otherwise. + +- **If fixing**: The fix itself is the response. No reply needed unless the fix is non-obvious. +- **If dismissing**: Reply to the comment thread with a brief explanation of why. Keep it to 1-2 sentences. Examples: + - "This is pre-existing behavior unrelated to this diff — the scope lookup fallback existed before caching was added. Worth a follow-up issue but not blocking this PR." + - "The AsyncExitStack handles cleanup when the session exits, so the subprocess isn't leaked — just kept alive slightly longer than necessary in this edge case." + - "Gemini supports a much wider range of media types than OpenAI/Anthropic, so a restrictive allowlist would be inaccurate here." + +Use `gh api` to reply (note: use `in_reply_to`, not a `/replies` sub-path): + +```bash +# Reply to a specific review comment +gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/comments \ + -f body="Your reply here" \ + -F in_reply_to={COMMENT_ID} +``` + +## The fix-push-review cycle + +After evaluating comments: + +1. Fix all real issues in one batch +2. Reply to all dismissed comments with reasoning +3. Think about what patterns Codex might flag next — check similar code paths proactively +4. Commit and push +5. Check that Codex reviews the new commit +6. Repeat until Codex gives a clean review (thumbs-up) or only has dismissible comments + +## Responding to stale comments + +Codex sometimes re-posts old comments that reference code you've already fixed (they appear on the old commit's diff). These are stale — verify the fix is in the latest commit and reply noting the fix is already in place. + +## When a PR is ready + +A PR is ready for human review when: +- All Codex comments are either fixed or replied to with dismissal reasoning +- CI checks pass +- The diff is clean and focused on the stated purpose diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 267df6812..1e6139cfb 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -3,33 +3,30 @@ description: Report a bug or unexpected behavior in FastMCP labels: [bug, pending] body: - - type: markdown - attributes: - value: Thanks for contributing to FastMCP! 🙏 - - type: markdown attributes: value: | + Thanks for reporting a bug! + + A good bug report is one of the most valuable contributions you can make — see [CONTRIBUTING.md](../../CONTRIBUTING.md). If the fix is straightforward, a PR is also welcome. + ### Before you submit - To help us help you, please: - - - 🔄 **Make sure you're testing on the latest version of FastMCP** - many issues are already fixed in newer versions - - 🔍 **Check if someone else has already reported this issue** or if it's been fixed on the main branch - - 📋 **You MUST include a copy/pasteable and properly formatted MRE** (minimal reproducible example) below or your issue may be closed without response - - 💡 **The ideal issue is a clear problem description and an MRE — that's it.** If you've done a genuine investigation and have a non-obvious insight into the root cause, include it. But please don't speculate or ask an LLM to generate a diagnosis or proposed fix. We have LLMs too, and an incorrect analysis is harder to work with than none at all. - - ✂️ **Keep it short.** A one-paragraph description and a working MRE is the ideal bug report. Issues that are difficult to parse — due to length, speculation, or generated content — may be closed without response. - - Thanks for helping to make FastMCP better! 🚀 + - Make sure you're testing on the **latest version** of FastMCP — many issues are already fixed in newer releases + - Check if someone else has **already reported this** or if it's been fixed on the main branch + - You **must** include a copy/pasteable, properly formatted MRE (minimal reproducible example) or your issue may be closed without response + - **The ideal issue is a clear problem description and an MRE — that's it.** If you've done genuine investigation and have a non-obvious insight into the root cause, include it. But please don't speculate or ask an LLM to generate a diagnosis. We have LLMs too, and an incorrect analysis is harder to work with than none at all. + - **Keep it short.** A clear description plus a concise MRE is ideal — aim to fit in a single screen. Issues that include unsolicited root cause analysis, proposed fixes, or multi-section diagnostic writeups will be labeled `too-long` and not triaged until condensed. + - **Using an LLM?** Great — but it must follow these guidelines. Generic LLM output that ignores our contributing conventions will be closed. See [CONTRIBUTING.md](../../CONTRIBUTING.md). - type: textarea id: description attributes: - label: Description + label: What happened? description: | - Please explain what you're experiencing and what you would expect to happen instead. + Describe the bug in a few sentences. What did you do, what happened, and what did you expect instead? - Provide as much detail as possible to help us understand and solve your problem quickly. + Do NOT include root cause analysis, proposed fixes, or diagnostic writeups — just describe the problem. validations: required: true diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml index a803ec399..39c66647d 100644 --- a/.github/ISSUE_TEMPLATE/enhancement.yml +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -3,33 +3,27 @@ description: Suggest an idea or improvement for FastMCP labels: [enhancement, pending] body: - - type: markdown - attributes: - value: Thanks for contributing to FastMCP! 🙏 - - type: markdown attributes: value: | + Thanks for suggesting an improvement to FastMCP! + + Enhancement issues are the **primary way** features and improvements get into FastMCP. Maintainers use well-written issues to implement changes that fit the codebase's patterns and ship quickly. A clear issue here is more impactful than a PR — see [CONTRIBUTING.md](../../CONTRIBUTING.md) for why. + ### Before you submit - To help us evaluate your enhancement request: - - - 🔍 **Check if this has already been requested** - search existing issues first - - 💭 **Think about the broader impact** - how would this affect other users? - - 📋 **Consider implementation complexity** - is this a small change or a major feature? - - ✂️ **Keep it short.** Describe the problem you're trying to solve and why existing behavior falls short. Skip proposed implementations unless you have a specific, well-considered suggestion — we don't need LLM-generated API designs. Requests that are difficult to parse may be closed without response. - - Thanks for helping to make FastMCP better! 🚀 + - 🔍 **Check if this has already been requested** — search existing issues first + - 🎯 **Describe the problem you're trying to solve**, not the solution you want — we'll figure out the best implementation + - ✂️ **Keep it short.** A motivating description and a concrete use case is the ideal request — aim to fit in a single screen. Skip proposed implementations, API designs, or multi-option analyses — maintainers will figure out the approach. Requests that are difficult to parse will be labeled `too-long` and not triaged until condensed. + - 🤖 **Using an LLM?** Great — but it must follow these guidelines. Generic LLM output that ignores our contributing conventions will be closed. See [CONTRIBUTING.md](../../CONTRIBUTING.md). - type: textarea id: description attributes: label: Enhancement description: | - Please describe the enhancement: + What problem or use case does this solve? How does current behavior fall short? - - What problem or use case would it solve? - - How would it improve your workflow or experience with FastMCP? - - Are there any alternative solutions you've considered? + Focus on the *what* and *why* — the motivating scenario. You don't need to propose an API or implementation. validations: required: true diff --git a/.github/actions/run-pytest/action.yml b/.github/actions/run-pytest/action.yml index ff429a4cc..b7e5509e5 100644 --- a/.github/actions/run-pytest/action.yml +++ b/.github/actions/run-pytest/action.yml @@ -3,7 +3,7 @@ description: "Run pytest with appropriate flags for the test type and platform" inputs: test-type: - description: "Type of tests to run: unit, integration, or client_process" + description: "Type of tests to run: unit, integration, client_process, or conformance" required: false default: "unit" @@ -23,8 +23,13 @@ runs: TIMEOUT="5" MAX_PROCS="0" EXTRA_FLAGS="-x" + elif [ "${{ inputs.test-type }}" == "conformance" ]; then + MARKER="conformance" + TIMEOUT="120" + MAX_PROCS="0" + EXTRA_FLAGS="-x" else - MARKER="not integration and not client_process" + MARKER="not integration and not client_process and not conformance" TIMEOUT="5" MAX_PROCS="4" EXTRA_FLAGS="" @@ -38,6 +43,7 @@ runs: uv run --no-sync pytest \ --inline-snapshot=disable \ --timeout=$TIMEOUT \ + --durations=50 \ -m "$MARKER" \ $PARALLEL_FLAGS \ $EXTRA_FLAGS \ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 68212c72f..1b3333782 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,28 +1,22 @@ ## Description - + -**Contributors Checklist** - +## Contribution type -- [ ] My change closes #(issue number) -- [ ] I have followed the repository's development workflow -- [ ] I have tested my changes manually and by adding relevant tests -- [ ] I have performed all required documentation updates + -**Review Checklist** - +- [ ] Bug fix (simple, well-scoped fix for a clearly broken behavior) +- [ ] Documentation improvement +- [ ] Enhancement (maintainers typically implement enhancements — see [CONTRIBUTING.md](../CONTRIBUTING.md)) +## Checklist + +- [ ] This PR addresses an existing issue (or fixes a self-evident bug) +- [ ] I have read [CONTRIBUTING.md](../CONTRIBUTING.md) +- [ ] I have added tests that cover my changes +- [ ] I have run `uv run prek run --all-files` and all checks pass - [ ] I have self-reviewed my changes -- [ ] My Pull Request is ready for review - ---- +- [ ] If I used an LLM, it followed the repo's contributing conventions (not generic output) diff --git a/.github/release.yml b/.github/release.yml index 5ff95aace..5397d75e4 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -8,12 +8,25 @@ changelog: labels: - feature - - title: Enhancements 🔧 + - title: Breaking Changes ⚠️ + labels: + - breaking change + exclude: + labels: + - contrib + - security + + - title: Enhancements ✨ labels: - enhancement exclude: labels: - breaking change + - security + + - title: Security 🔒 + labels: + - security - title: Fixes 🐞 labels: @@ -21,13 +34,7 @@ changelog: exclude: labels: - contrib - - - title: Breaking Changes 🛫 - labels: - - breaking change - exclude: - labels: - - contrib + - security - title: Docs 📚 labels: @@ -41,6 +48,9 @@ changelog: - title: Dependencies 📦 labels: - dependencies + exclude: + labels: + - security - title: Other Changes 🦾 labels: diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml index d358a3a1e..ce601c5c3 100644 --- a/.github/workflows/auto-close-duplicates.yml +++ b/.github/workflows/auto-close-duplicates.yml @@ -20,7 +20,7 @@ jobs: - name: Generate Marvin App token id: marvin-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/auto-close-needs-mre.yml b/.github/workflows/auto-close-needs-mre.yml index 4338c58d8..de2fd0422 100644 --- a/.github/workflows/auto-close-needs-mre.yml +++ b/.github/workflows/auto-close-needs-mre.yml @@ -20,7 +20,7 @@ jobs: - name: Generate Marvin App token id: marvin-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/martian-test-failure.yml b/.github/workflows/martian-test-failure.yml index 9f7724fbd..5d9f7d4ae 100644 --- a/.github/workflows/martian-test-failure.yml +++ b/.github/workflows/martian-test-failure.yml @@ -29,7 +29,7 @@ jobs: - name: Generate Marvin App token id: marvin-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/martian-triage-issue.yml b/.github/workflows/martian-triage-issue.yml index 4c7ef711e..cb5c8b55d 100644 --- a/.github/workflows/martian-triage-issue.yml +++ b/.github/workflows/martian-triage-issue.yml @@ -33,7 +33,7 @@ jobs: - name: Generate Marvin App token id: marvin-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/marvin-comment-on-issue.yml b/.github/workflows/marvin-comment-on-issue.yml index 8d297a226..8029d4ab9 100644 --- a/.github/workflows/marvin-comment-on-issue.yml +++ b/.github/workflows/marvin-comment-on-issue.yml @@ -36,14 +36,9 @@ jobs: - name: Install dependencies run: uv sync --python 3.12 - - name: Run prek - uses: j178/prek-action@v1 - env: - SKIP: no-commit-to-branch - - name: Generate Marvin App token id: marvin-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/marvin-comment-on-pr.yml b/.github/workflows/marvin-comment-on-pr.yml index 09f699522..9e4e4fd9d 100644 --- a/.github/workflows/marvin-comment-on-pr.yml +++ b/.github/workflows/marvin-comment-on-pr.yml @@ -38,14 +38,9 @@ jobs: - name: Install dependencies run: uv sync --python 3.12 - - name: Run prek - uses: j178/prek-action@v1 - env: - SKIP: no-commit-to-branch - - name: Generate Marvin App token id: marvin-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml index a71c590c0..727063f9a 100644 --- a/.github/workflows/marvin-dedupe-issues.yml +++ b/.github/workflows/marvin-dedupe-issues.yml @@ -25,7 +25,7 @@ jobs: - name: Generate Marvin App token id: marvin-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/marvin-label-triage.yml b/.github/workflows/marvin-label-triage.yml index 8a3f5f47d..0e74fedbe 100644 --- a/.github/workflows/marvin-label-triage.yml +++ b/.github/workflows/marvin-label-triage.yml @@ -36,7 +36,7 @@ jobs: - name: Generate Marvin App token id: marvin-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} @@ -49,7 +49,7 @@ jobs: PROMPT<` (e.g., `v3.2.0`). Always pass `--generate-notes` so the auto-generated changelog appears at the bottom. + +**The title pun is critical.** Titles follow `v: ` where the pun relates to the most important theme of the release. Propose multiple options and let the maintainer choose — never pick one yourself. Look at recent releases for tone (e.g., "Code to Joy" for the code mode release, "Three at Last" for 3.0). + +Write the maintainer-approved handwritten notes to a temporary file, then create the release. `--generate-notes` appends the auto-generated changelog after the handwritten content. + +```bash +gh release create v3.2.0 --target main --title "v3.2.0: Theme Here" --generate-notes --notes-file /tmp/release-notes.md +``` + +Most releases target `main`, but maintenance or backport releases may target a different branch (e.g., `release/2.x`). Confirm the target with the maintainer if there's any ambiguity. + +The handwritten notes are prepended above the auto-generated changelog and are the part that matters. Do not include a title in the notes body — the release title (`v{version}: {pun}`) already serves as the heading. Work with the maintainer to draft the notes — propose a draft, get feedback, iterate. Do not publish without the maintainer's sign-off. + +**Before drafting, always read recent existing releases** (`gh release list` then `gh release view `) to absorb the voice, structure, and level of detail. Each release builds on the tone of previous ones — don't guess at the style from these instructions alone. + +**Point releases** (3.0, 3.1, 3.2) get narrative prose: open with the theme of the release, then walk through headline features conceptually — what they enable, why they matter, how they fit together. Write it the way a blog post reads, not a changelog. Multiple paragraphs, code examples where they clarify. + +**Patch releases** (3.1.1, 3.0.2) get 1-2 sentences explaining what broke and what the fix does. Keep it minimal — the auto-generated changelog has the details. + ### Commit Messages and Agent Attribution - **Agents NOT acting on behalf of @jlowin MUST identify themselves** (e.g., "🤖 Generated with Claude Code" in commits/PRs) @@ -79,6 +103,14 @@ When modifying MCP functionality, changes typically need to be applied across al - Minor fixes: keep body short and concise - No "test plan" sections or testing summaries +### Code Review Guidelines + +- **Fix causes, not symptoms.** When a PR works around a problem instead of addressing why it occurs, that's a red flag. A side-channel that compensates for a missing step adds permanent complexity. If the fix doesn't change the code path where the bug actually happens, ask why not. +- Focus on API design and naming clarity +- Identify confusing patterns (e.g., parameter values that contradict defaults) or non-idiomatic code (mutable defaults, etc.). Contributed code will need to be maintained indefinitely, and by someone other than the author (unless the author is a maintainer). +- Suggest specific improvements, not generic "add more tests" comments +- Think about API ergonomics from a user perspective + ### Code Standards - Python ≥ 3.10 with full type annotations @@ -102,6 +134,7 @@ When modifying MCP functionality, changes typically need to be applied across al - Do not manually modify `docs/python-sdk/**` — these files are auto-generated from source code by a bot and maintained via a long-lived PR. Do not include changes to these files in contributor PRs. - Do not manually modify `docs/public/schemas/**` or `src/fastmcp/utilities/mcp_server_config/v1/schema.json` — these are auto-generated and maintained via a long-lived PR. - **Core Principle:** A feature doesn't exist unless it is documented! +- When adding or modifying settings in `src/fastmcp/settings.py`, update `docs/more/settings.mdx` to match. ### Documentation Guidelines @@ -110,6 +143,7 @@ When modifying MCP functionality, changes typically need to be applied across al - **Structure:** Headers form navigation guide, logical H2/H3 hierarchy - **Content:** User-focused sections, motivate features (why) before mechanics (how) - **Style:** Prose over code comments for important information +- **Docstrings:** FastMCP docstrings are automatically compiled into MDX documents. Use markdown (single backticks, fenced code blocks), not RST (no double backticks). Bare `{}` in examples will be interpreted as JSX — wrap in backticks instead. ## Critical Patterns diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..6f861c50a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to FastMCP + +FastMCP is an actively maintained, high-traffic project. We welcome contributions — but the most impactful way to contribute might not be what you expect. + +## The best contribution is a great issue + +FastMCP is an opinionated framework, and its maintainers use AI-assisted tooling that is deeply tuned to those opinions — the design philosophy, the API patterns, the way the framework is meant to evolve. A well-written issue with a clear problem description is often more valuable than a pull request, because it lets maintainers produce a solution that isn't just correct, but consistent with how the framework wants to work. That matters more than speed, though it's faster too. + +**A great issue looks like this:** + +1. A short, motivating description of the problem or gap +2. A minimal reproducible example (for bugs) or a concrete use case (for enhancements) +3. A brief note on expected vs. actual behavior + +That's it. No need to diagnose root causes, propose API designs, or suggest implementations. If you've done genuine investigation and have a non-obvious insight, include it. + +## Using AI to contribute + +We encourage you to use LLMs to help identify bugs, write MREs, and prepare contributions. But if you do, your LLM must take into account the conventions and contributing guidelines of this repo — including how we want issues formatted and when it's appropriate to open a PR. Generic LLM output that ignores these guidelines tells us the contribution wasn't made thoughtfully, and we will close it. A good AI-assisted contribution is indistinguishable from a good human one. A bad one is obvious. + +## When to open a pull request + +An open issue is not an invitation to submit a PR. Issues track problems; whether and how to solve them is a separate decision. If you want to work on something, propose your approach in the issue first — especially for anything beyond a trivial fix. + +**Bug fixes** — PRs are welcome for simple, well-scoped bug fixes where the problem and solution are both straightforward. "The function raises `TypeError` when passed `None` because of a missing guard" is a good candidate. If the fix requires design decisions or touches multiple subsystems, open an issue with a design proposal instead. + +**Documentation** — Typo fixes, clarifications, and improvements to examples are always welcome as PRs. + +**Enhancements and features** — We welcome enhancement PRs, but our experience is that most contributors — even when using LLMs — implement fixes that address the one instance of a problem they encountered rather than understanding why the framework produces that problem and fixing it at the right layer. This creates branching, patch-style code that's difficult to maintain and makes it impossible to reason about the framework as a coherent system. For this reason, enhancements need a design proposal in the issue before code is written. The proposal doesn't need to be long — just enough to show you've thought about how the change fits into the framework, not just how it solves your immediate case. + +**Integrations** — FastMCP generally does not accept PRs that add third-party integrations (custom middleware, provider-specific adapters, etc.). If you're building something for your users, ship it as a standalone package — that's a feature, not a limitation. Authentication providers are an exception, since auth is tightly coupled to the framework. + +## PR guidelines + +If you do open a PR: + +- **Reference an issue.** Every PR should address a tracked issue. If there isn't one, open an issue first. This isn't a permission step — you don't need to wait for a response. But the issue gives us context on the problem, and if a maintainer is already working on it, we can let you know before you invest time in code. +- **Keep it focused.** One logical change per PR. Don't bundle unrelated fixes or refactors. +- **Match existing patterns.** Follow the code style, type annotation conventions, and test patterns you see in the codebase. Run `uv run prek run --all-files` before submitting. +- **Write tests.** Bug fixes should include a test that fails without the fix. Enhancements should include tests for the new behavior. +- **Fix the cause, not the symptom.** If the bug is that a code path skips a step, the fix should make it stop skipping that step — not add compensation elsewhere. Workaround-style fixes will be sent back for revision. +- **Don't submit generated boilerplate.** We review every line. PRs that read like unedited LLM output — verbose descriptions, speculative changes, shotgun-style fixes — will be closed. + +## What we'll close without review + +To keep the project maintainable, we will close PRs that: + +- Don't reference an issue or address a clearly self-evident bug +- Make sweeping changes without prior discussion +- Add third-party integrations that belong in a separate package +- Are difficult to review due to size, scope, or generated content + +This isn't personal — contributing to a framework is different from contributing to an application. In an application, a fix that works is a good fix. In a framework, a fix that works but doesn't fit the framework's design creates maintenance burden that compounds over time. Every patch that works around a problem instead of solving it at the right layer makes the system harder for *everyone* to reason about — maintainers, contributors, and users. We hold contributions to this standard because the alternative is a codebase that's a series of patches rather than a coherent system. A good issue is often the best thing you can do for the project. diff --git a/README.md b/README.md index a5c7cd1fd..47787b295 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,13 @@ FastMCP has three pillars: **[Servers](https://gofastmcp.com/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](https://gofastmcp.com/clients/client)** connect to any server with full protocol support. And **[Apps](https://gofastmcp.com/apps/overview)** give your tools interactive UIs rendered directly in the conversation. -Ready to build? Start with the [installation guide](https://gofastmcp.com/getting-started/installation) or jump straight to the [quickstart](https://gofastmcp.com/getting-started/quickstart). When you're ready to deploy, [Prefect Horizon](https://www.prefect.io/horizon) offers free hosting for FastMCP users. +Ready to build? Start with the [installation guide](https://gofastmcp.com/getting-started/installation) or jump straight to the [quickstart](https://gofastmcp.com/getting-started/quickstart). + +## Run FastMCP in production with Horizon + +FastMCP is how teams build MCP servers. **[Prefect Horizon](https://www.prefect.io/horizon)** is how enterprises run them in production. Register any MCP server behind a managed gateway with SSO, tool-level RBAC, audit logs, and observability. Deploy FastMCP servers and go from PR to preview in 60 seconds, then remix tools from across your registry into use-case-specific, permissioned endpoints. Horizon is everything we've learned about MCP at scale from building the world's most popular MCP framework. Free for individuals, built for teams. + +[Deploy FastMCP with Horizon →](https://www.prefect.io/horizon) ## Installation diff --git a/SECURITY.md b/SECURITY.md index 8e1943ad8..656867d37 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,15 +2,33 @@ ## Supported Versions -FastMCP v2.x receives security updates. Earlier versions are no longer supported. - | Version | Supported | | ------- | ------------------ | -| 2.x | :white_check_mark: | -| < 2.0 | :x: | +| 3.x | :white_check_mark: | +| 2.x | :x: | +| 1.x | :x: | +| 0.x | :x: | ## Reporting a Vulnerability -Please report security vulnerabilities privately using [GitHub's security advisory feature](https://github.com/PrefectHQ/fastmcp/security/advisories/new). +Please report security vulnerabilities privately using [GitHub's security advisory feature](https://github.com/PrefectHQ/fastmcp/security/advisories/new). Do not open public issues for security concerns. -Do not open public issues for security concerns. +## Scope + +We accept reports for vulnerabilities in FastMCP itself — the library code in this repository. + +The following are **out of scope**: + +- Vulnerabilities in third-party dependencies or the MCP SDK itself. We'll bump version floors for known CVEs, but the fix belongs upstream. +- Limitations of upstream identity providers that FastMCP cannot control. +- Issues that require the attacker to already have server-side access or control of the MCP server configuration. + +## Disclosure Process + +When we receive a valid report: + +1. We triage the report and determine whether it affects FastMCP directly. +2. We develop and test a fix on a private branch. +3. We coordinate CVE assignment through GitHub's advisory process when warranted. +4. We publish the advisory and release a patched version. +5. We credit the reporter in the advisory (unless they prefer otherwise). diff --git a/docs/apps/architecture.mdx b/docs/apps/architecture.mdx new file mode 100644 index 000000000..26588c2f8 --- /dev/null +++ b/docs/apps/architecture.mdx @@ -0,0 +1,119 @@ +--- +title: App Architecture +sidebarTitle: Architecture +description: How FastMCP apps work under the hood — from Python to pixels. +icon: sitemap +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +This page explains how Prefab apps work under the hood — how your Python code becomes an interactive UI inside a host client's conversation. You don't need any of this to build apps, but the mental model is useful when something isn't rendering the way you expect, when tool calls from the UI aren't reaching your server, or when you're building [custom HTML apps](/apps/low-level) and need to understand the protocol directly. + +## The Pipeline + +An MCP App moves through five stages from Python to pixels: + +``` +Python components → JSON tree → structuredContent → Renderer iframe → Host UI +``` + +You write Prefab components in Python. FastMCP serializes them to a JSON component tree and delivers it as `structuredContent` on the tool result. The host loads the Prefab renderer in a sandboxed iframe, pushes the JSON into it, and the renderer paints the UI. If the UI needs to call server tools, it talks back through the same `postMessage` channel. + +The following sections walk through each stage. + +## Tool Registration + +When you mark a tool with `app=True` or `@app.ui()`, FastMCP wires up the metadata and renderer resource that the protocol requires. + +### The `app=True` Flag + +The `app` parameter on `@mcp.tool` accepts `True`, an `AppConfig` object, or a dict. When you pass `True`, FastMCP checks whether the tool's return type is a Prefab type (`PrefabApp`, `Component`, or unions containing them). If the tool qualifies, FastMCP expands `True` into a full `AppConfig` — setting the renderer URI, CSP headers, and visibility — and stores it in the tool's `meta["ui"]` dict. + +This expansion also triggers registration of the shared Prefab renderer resource (discussed below). The tool and the renderer are linked through a `resourceUri` field in the metadata: the tool says "render me with `ui://prefab/renderer.html`", and the host fetches that resource when it needs to display the result. + +Type inference works the same way. If your return type annotation is a Prefab type and you haven't set `app` explicitly, FastMCP auto-wires the metadata as if you'd written `app=True`. + +### FastMCPApp Registration + +`FastMCPApp` uses the same underlying mechanism but adds two things. First, it tags every tool — both `@app.ui()` entry points and `@app.tool()` backends — with `meta["fastmcp"]["app"]` set to the app's name. This tag is how the server identifies which app a tool belongs to when routing calls from the UI. + +Second, it sets `meta["ui"]["visibility"]` to control who can see each tool. Entry points default to `["model"]` (visible to the LLM). Backend tools default to `["app"]` (visible only to the UI). Hosts use this to filter the tool list — the model sees entry points, and the UI sees backends. + +## Serialization + +When a Prefab tool runs, its return value — a `PrefabApp` or a raw `Component` — needs to become a JSON blob that the renderer can interpret. + +### PrefabApp.to_json() + +The serialization entry point is `PrefabApp.to_json()`. This method walks the component tree and produces a JSON object with three top-level keys: `view` (the component tree), `state` (initial state values), and `_meta` (routing metadata). + +FastMCP passes a `tool_resolver` callback to `to_json()`. Whenever the component tree contains a `CallTool` action that references a function (not a string), the resolver converts it to a `ResolvedTool` with the function's registered name. This is how `CallTool(save_contact)` becomes `CallTool("save_contact")` in the wire format. The resolver also handles `unwrap_result` — a flag that tells the renderer to unwrap single-value results from the `{"result": value}` envelope that FastMCP uses for schema compliance. + +### The _meta.fastmcp.app Tag + +After `to_json()` produces the JSON tree, FastMCP injects `_meta.fastmcp.app` with the app's name (if the tool belongs to a `FastMCPApp`). This tag rides along inside `structuredContent` all the way to the renderer. + +When the renderer calls a backend tool, it includes `_meta.fastmcp.app` in the `CallTool` request. The server sees this tag and routes the call through a special path that bypasses transforms — more on this in the next section. + +### ToolResult Assembly + +The final tool result has two parts: `content` (a list of `TextContent` blocks for the LLM) and `structuredContent` (the JSON tree for the renderer). By default, Prefab tools send `"[Rendered Prefab UI]"` as the text content — just enough for the LLM to know something was rendered. If you return a `ToolResult` explicitly, you control both halves. + +## Tool Call Routing + +When a host calls a tool, the server needs to find it. Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters, etc.) before resolving the tool by name. But app UI calls need a different path. + +### The get_app_tool Bypass + +Backend tools registered with `@app.tool()` 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` — but the renderer still uses the original name. + +`get_app_tool` solves both problems. When the server sees `_meta.fastmcp.app` on an incoming `CallTool` request, it calls `get_app_tool(app_name, tool_name)` instead of the normal `get_tool(name)`. This method walks the provider tree directly, skipping the transform chain entirely. It finds the tool by its original registered name and verifies that its `meta["fastmcp"]["app"]` matches the expected app identity. + +This is why `CallTool("save_contact")` keeps working even when the server is mounted under a namespace prefix. The renderer sends the original name plus the app identity; the server uses `get_app_tool` to find the tool without transforms getting in the way. + +Authorization checks still apply — `get_app_tool` bypasses transforms, but it runs auth checks against the tool's `auth` configuration before executing. + +### Provider Delegation + +The `get_app_tool` method is defined on the `Provider` base class and overridden by aggregate and wrapped providers. Aggregate providers fan out the lookup across all child providers in parallel. Wrapped providers (like `FastMCPProvider`, which wraps a nested `FastMCP` server) delegate to the inner server's `get_app_tool`. This means backend tools are reachable through any depth of server composition. + +## The Renderer + +The Prefab renderer is a self-contained JavaScript application that interprets the JSON component tree and renders it as a React UI. + +### The Shared Resource + +FastMCP registers the renderer as a `ui://prefab/renderer.html` resource with MIME type `text/html;profile=mcp-app`. The renderer HTML is bundled inside the `prefab-ui` Python package — `get_renderer_html()` returns it as a string. All Prefab tools on a server share this single resource, regardless of how many tools or apps are registered. + +The resource also carries CSP metadata (via `get_renderer_csp()`) declaring which CDN domains the renderer needs to load its JavaScript dependencies. Hosts use this to configure the iframe's Content Security Policy. + +### postMessage Communication + +The renderer lives in a sandboxed iframe. It communicates with the host using `postMessage` — the standard browser API for cross-origin iframe communication. The protocol follows the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) specification: + +The host pushes the tool result (including `structuredContent`) into the iframe. The renderer parses the JSON component tree, initializes state, and renders the UI. When the user interacts with the UI — submitting a form, clicking a button — and that interaction triggers a `CallTool` action, the renderer sends a `callServerTool` message back to the host via `postMessage`. The host forwards this as a regular MCP `tools/call` request to the server, including `_meta.fastmcp.app` for routing. + +The response flows back the same way: server to host, host to iframe via `postMessage`, renderer updates state with the result. + +### AppBridge + +The `@modelcontextprotocol/ext-apps` JavaScript SDK provides the `App` class (sometimes called AppBridge) that manages the `postMessage` handshake. It handles connection negotiation, tool result delivery, server tool calls, and host context (like safe area insets and theme preferences). The Prefab renderer uses this SDK internally — you only interact with it directly when building [custom HTML apps](/apps/low-level). + +## The Dev Server + +`fastmcp dev apps` provides a local preview environment that simulates the host-side behavior without requiring a real MCP host client. + +### Proxy Architecture + +The dev server runs two HTTP servers. Your MCP server starts on port 8000 (configurable) with the Streamable HTTP transport. The dev UI runs on port 8080 and serves a picker page that lists your app tools. + +A reverse proxy at `/mcp` on the dev server forwards requests to your MCP server. This is important because the renderer iframe runs on `localhost:8080`, and your MCP server runs on `localhost:8000`. Without the proxy, the renderer's `callServerTool` requests would be cross-origin and blocked by the browser. The proxy makes everything same-origin from the iframe's perspective. + +### The Launch Flow + +When you select a tool and click launch, the dev UI calls the tool through the proxy, receives the `structuredContent` response, and opens a new tab. That tab loads the tool's renderer resource (fetched from the proxy) in an iframe, creates an AppBridge instance, and pushes the tool result into the renderer. From this point forward, the experience matches what a real host would provide — the renderer displays the UI, and any `CallTool` actions route back through the proxy to your MCP server. + +Auto-reload is enabled by default, so changes to your server code restart the MCP server automatically. The dev UI stays running — just re-launch the tool to see your changes. diff --git a/docs/apps/development.mdx b/docs/apps/development.mdx new file mode 100644 index 000000000..7045cd939 --- /dev/null +++ b/docs/apps/development.mdx @@ -0,0 +1,66 @@ +--- +title: Development +sidebarTitle: Development +description: Preview and test your app tools locally without a full MCP host. +icon: flask +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + + + The dev UI showing a rendered Prefab app with the MCP inspector panel + + +`fastmcp dev apps` launches a browser-based preview for your app tools. It starts your MCP server and a local dev UI side by side — you pick a tool, fill in its arguments, and see the rendered result in a new tab. No MCP host client needed. + +This works with both [Prefab apps](/apps/prefab) and [custom HTML apps](/apps/low-level). + +## Quick Start + +```bash +fastmcp dev apps server.py +``` + +The dev UI opens at `http://localhost:8080`. Your MCP server runs on port 8000 with auto-reload enabled by default — save a file and the server restarts automatically. + +## How It Works + +The dev server does three things: + +The **picker page** connects to your MCP server, finds all tools with UI metadata, and renders a form for each one. The forms are auto-generated from the tool's input schema — text fields, dropdowns, checkboxes, all wired up. + +When you submit a form, the dev server **calls your tool** via the MCP protocol and opens the result in a new tab. The result page loads the tool's UI resource (the Prefab renderer or your custom HTML) inside an AppBridge — the same protocol that real MCP hosts use. + +A **reverse proxy** on `/mcp` forwards requests from the browser to your MCP server, avoiding CORS issues that would otherwise block the iframe-based renderer from talking to a different port. + +## MCP Inspector + +The dev UI includes an inspector panel on the left side that captures MCP traffic in real time. It shows JSON-RPC messages flowing between the browser and your server — requests, responses, and AppBridge `postMessage` traffic. + +Each entry shows direction, method, timing, and a smart summary. Click any entry to expand the full JSON-RPC body. The panel auto-scrolls to new messages unless you've scrolled up to inspect older ones. + +The inspector is useful for debugging: you can see exactly what arguments your tool received, what it returned, and how the AppBridge communicated with the renderer. + +## Options + +```bash +fastmcp dev apps server.py:mcp --mcp-port 9000 --dev-port 9090 --no-reload +``` + +| Option | Flag | Default | Description | +| ------ | ---- | ------- | ----------- | +| MCP Port | `--mcp-port` | `8000` | Port for your MCP server | +| Dev Port | `--dev-port` | `8080` | Port for the dev UI | +| Auto-Reload | `--reload` / `--no-reload` | On | Watch files and restart the server on changes | + +## Multiple Tools + +If your server has multiple app tools, the picker shows a dropdown. Each tool gets its own form and launch button. The tool's `title` is displayed when available, falling back to the tool name. + +```bash +# Server with multiple app tools +fastmcp dev apps examples/apps/contacts/contacts_server.py +``` diff --git a/docs/apps/examples.mdx b/docs/apps/examples.mdx new file mode 100644 index 000000000..024808f5d --- /dev/null +++ b/docs/apps/examples.mdx @@ -0,0 +1,140 @@ +--- +title: Examples +sidebarTitle: Examples +description: Example apps you can run right now. +icon: images +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +Every example below is a working FastMCP server you can run with `fastmcp dev apps` or connect to from any MCP host. The source is in `examples/apps/` in the repository. + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +## Running Examples + +Preview any example in your browser with the dev server: + +```bash +pip install "fastmcp[apps]" +fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py +``` + +The dev server opens an interactive browser UI where you can select a tool and provide arguments. In a real deployment, the LLM provides these arguments on the fly based on the conversation. For example, the quiz example works best when connected to an MCP host like Goose or Claude Desktop, where the LLM generates the questions itself. + +## Standalone Examples + +### Sales Dashboard + +A full dashboard with KPI metrics, revenue trends, segment breakdown, and a deal pipeline table. Shows what you can build with a single `app=True` tool and Prefab's chart and data components. + +```bash +fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py +``` + +### System Monitor + +Reads live CPU, memory, and disk stats from your machine using `psutil`. Auto-refreshes via `SetInterval` calling a backend tool, with a dropdown to control the refresh rate. The chart accumulates 100 data points over time. + +```bash +pip install psutil +fastmcp dev apps examples/apps/system_monitor/system_monitor_server.py +``` + +### Quiz + +The LLM generates trivia questions and passes them to the tool. The user answers via buttons, sees correct/incorrect feedback, and tracks score across questions. Demonstrates multi-turn client-side state with FastMCPApp. + +```bash +fastmcp dev apps examples/apps/quiz/quiz_server.py +``` + +### Interactive Map + +Accepts addresses or place names, geocodes them via OpenStreetMap Nominatim (free, no API key), and renders an interactive Leaflet map using Prefab's `Embed` component with inline HTML. Proves that Prefab apps aren't limited to built-in components. + +```bash +fastmcp dev apps examples/apps/map/map_server.py +``` + +## Built-in Providers + +These are ready-made capabilities you add with a single `add_provider()` call. + +### [File Upload](/apps/providers/file-upload) + +Drag-and-drop file upload. The user drops files, clicks Upload, and the server stores them. The LLM can list and read uploaded files through model-visible tools. + +```python +from fastmcp.apps.file_upload import FileUpload +mcp.add_provider(FileUpload()) +``` + +### [Approval](/apps/providers/approval) + +Human-in-the-loop confirmation. The LLM presents what it's about to do, the user clicks Approve or Reject, and the decision flows back as a message. + +```python +from fastmcp.apps.approval import Approval +mcp.add_provider(Approval()) +``` + +### [Choice](/apps/providers/choice) + +Present clickable options instead of asking users to type. Clean structured input without parsing free text. + +```python +from fastmcp.apps.choice import Choice +mcp.add_provider(Choice()) +``` + +### [Form Input](/apps/providers/form) + +Generate a validated form from a Pydantic model. Submission is validated against the model before being returned. + +```python +from fastmcp.apps.form import FormInput +mcp.add_provider(FormInput(model=MyModel)) +``` + +### [Generative UI](/apps/providers/generative) + +The LLM writes Prefab Python code at runtime and the result renders as a streaming interactive UI. Tailored visualizations for any data. See the [full guide](/apps/generative) for details. + +```python +from fastmcp.apps.generative import GenerativeUI +mcp.add_provider(GenerativeUI()) +``` diff --git a/docs/apps/generative.mdx b/docs/apps/generative.mdx new file mode 100644 index 000000000..86d306dd7 --- /dev/null +++ b/docs/apps/generative.mdx @@ -0,0 +1,133 @@ +--- +title: Generative UI +sidebarTitle: Generative UI +description: Let the LLM build custom Prefab UIs on the fly. +icon: wand-magic-sparkles +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +Generative UI means the LLM writes the UI code at runtime. Instead of calling a pre-built tool with a fixed interface, the model writes Prefab Python code tailored to the current data and request. The user watches the UI build up in real time as the model generates code. + +```python +from fastmcp import FastMCP +from fastmcp.apps.generative import GenerativeUI + +mcp = FastMCP("Prefab Studio") +mcp.add_provider(GenerativeUI()) +``` + +That's it. The `GenerativeUI` provider registers everything: + +- **`generate_prefab_ui`** — a tool that accepts Python code, executes it in a Pyodide sandbox, and renders the result as a Prefab app +- **`search_prefab_components`** — a tool that lets the LLM search the Prefab component library to discover what's available +- **The generative renderer** — a `ui://` resource with browser-side Pyodide for streaming progressive rendering + +## How It Works + +When the LLM decides to call `generate_prefab_ui`, it writes Prefab Python code into the `code` argument. The MCP Apps protocol creates the renderer iframe in parallel with the tool call, so the app is already running when partial arguments start flowing. + +As the LLM generates each token: + +1. The host forwards partial arguments to the app via `ontoolinputpartial` +2. The renderer extracts the growing `code` string +3. Browser-side Pyodide executes whatever compiles successfully +4. The user sees components appear as they're written + +When the LLM finishes, the server runs the complete code in a server-side Pyodide sandbox for validation, and the renderer replaces the streaming preview with the final server-validated result. + +## What the LLM Writes + +The tool description includes code examples that teach the LLM the Prefab patterns. A typical generation looks like: + +```python +from prefab_ui.components import Column, Row, Heading, Text, Badge, Card, CardContent +from prefab_ui.components.charts import BarChart, ChartSeries +from prefab_ui.app import PrefabApp + +with PrefabApp() as app: + with Column(gap=6, css_class="p-6"): + Heading("Q3 Revenue Report") + + BarChart( + data=[ + {"month": "Jul", "revenue": 42000}, + {"month": "Aug", "revenue": 51000}, + {"month": "Sep", "revenue": 63000}, + ], + series=[ChartSeries(data_key="revenue", label="Revenue")], + x_axis="month", + ) + + with Row(gap=4): + with Card(): + with CardContent(): + Text("Total", css_class="text-sm text-muted-foreground") + Heading("$156,000") + with Card(): + with CardContent(): + Text("Growth", css_class="text-sm text-muted-foreground") + Badge("+18%", variant="success") +``` + +The model writes real Python — loops, f-strings, computation, helper functions. Prefab's component library gives it charts, tables, forms, cards, badges, and layout primitives to work with. + +## The Component Search Tool + +Before writing code, the LLM can call `search_prefab_components` to discover what's available: + +``` +search_prefab_components("Chart") +→ 7 components matching 'Chart': + AreaChart — from prefab_ui.components.charts import AreaChart + BarChart — from prefab_ui.components.charts import BarChart + ... +``` + +Passing `detail=True` returns full field descriptions and docstrings. The search tool introspects the actual Prefab classes at runtime, so it's always up to date with the installed version. + +## Passing Data + +The `generate_prefab_ui` tool accepts a `data` parameter. Values passed here become global variables in the sandbox: + +```python +# The LLM can reference 'sales_data' directly in its code +result = await generate_prefab_ui( + code="...", + data={"sales_data": [{"month": "Jan", "revenue": 42000}, ...]} +) +``` + +This lets the model use real data from earlier in the conversation to build visualizations. + +## Configuration + +`GenerativeUI` accepts options for customizing tool names: + +```python +GenerativeUI( + tool_name="generate_prefab_ui", # default + components_tool_name="search_prefab_components", # default + include_components_tool=True, # default +) +``` + +## Requirements + +Generative UI requires `fastmcp[apps]` which installs `prefab-ui`. The Pyodide sandbox (for server-side validation) requires Deno — it installs automatically on first use. + +The streaming renderer loads Pyodide from CDN in the browser. The CSP is configured automatically by the provider — no manual setup needed. + +## Sandbox Limitations + +The Pyodide sandbox includes the Python standard library and Prefab. External packages (NumPy, pandas, requests, etc.) are **not available** — the LLM's code must work with only built-in Python and Prefab components. If the LLM tries to import an unavailable package, the sandbox will raise an `ImportError`. + +## Next Steps + +- **[GenerativeUI Provider Reference](/apps/providers/generative)** — Configuration options and quick setup +- **[Prefab UI](/apps/prefab)** — The component library and state system the LLM writes code against +- **[Prefab Component Reference](https://prefab.prefect.io/docs/components)** — Full component library documentation +- **[Development](/apps/development)** — Preview generative UI tools locally with `fastmcp dev apps` diff --git a/docs/apps/images/app-approval.png b/docs/apps/images/app-approval.png new file mode 100644 index 000000000..162f4847f Binary files /dev/null and b/docs/apps/images/app-approval.png differ diff --git a/docs/apps/images/app-chart.png b/docs/apps/images/app-chart.png new file mode 100644 index 000000000..cfc816d0e Binary files /dev/null and b/docs/apps/images/app-chart.png differ diff --git a/docs/apps/images/app-choice.png b/docs/apps/images/app-choice.png new file mode 100644 index 000000000..178f6a2b0 Binary files /dev/null and b/docs/apps/images/app-choice.png differ diff --git a/docs/apps/images/app-contacts.png b/docs/apps/images/app-contacts.png new file mode 100644 index 000000000..5d74f7cb9 Binary files /dev/null and b/docs/apps/images/app-contacts.png differ diff --git a/docs/apps/images/app-datatable.png b/docs/apps/images/app-datatable.png new file mode 100644 index 000000000..e69de29bb diff --git a/docs/apps/images/app-example-map.png b/docs/apps/images/app-example-map.png new file mode 100644 index 000000000..5859c59c2 Binary files /dev/null and b/docs/apps/images/app-example-map.png differ diff --git a/docs/apps/images/app-example-quiz.png b/docs/apps/images/app-example-quiz.png new file mode 100644 index 000000000..b16bcaf43 Binary files /dev/null and b/docs/apps/images/app-example-quiz.png differ diff --git a/docs/apps/images/app-example-sales-dashboard.png b/docs/apps/images/app-example-sales-dashboard.png new file mode 100644 index 000000000..e0fe709a9 Binary files /dev/null and b/docs/apps/images/app-example-sales-dashboard.png differ diff --git a/docs/apps/images/app-example-system-dashboard.png b/docs/apps/images/app-example-system-dashboard.png new file mode 100644 index 000000000..7b85d7ac1 Binary files /dev/null and b/docs/apps/images/app-example-system-dashboard.png differ diff --git a/docs/apps/images/app-file-upload.png b/docs/apps/images/app-file-upload.png new file mode 100644 index 000000000..1178c09af Binary files /dev/null and b/docs/apps/images/app-file-upload.png differ diff --git a/docs/apps/images/app-form.png b/docs/apps/images/app-form.png new file mode 100644 index 000000000..30567e37e Binary files /dev/null and b/docs/apps/images/app-form.png differ diff --git a/docs/apps/images/app-greet.png b/docs/apps/images/app-greet.png new file mode 100644 index 000000000..70a0e4412 Binary files /dev/null and b/docs/apps/images/app-greet.png differ diff --git a/docs/apps/images/app-overview.png b/docs/apps/images/app-overview.png new file mode 100644 index 000000000..35f68fd58 Binary files /dev/null and b/docs/apps/images/app-overview.png differ diff --git a/docs/apps/images/app-quickstart-dev-2.png b/docs/apps/images/app-quickstart-dev-2.png new file mode 100644 index 000000000..f04d96d72 Binary files /dev/null and b/docs/apps/images/app-quickstart-dev-2.png differ diff --git a/docs/apps/images/app-quickstart-dev.png b/docs/apps/images/app-quickstart-dev.png new file mode 100644 index 000000000..d043f0ed3 Binary files /dev/null and b/docs/apps/images/app-quickstart-dev.png differ diff --git a/docs/apps/images/app-quickstart.png b/docs/apps/images/app-quickstart.png new file mode 100644 index 000000000..ddca745cf Binary files /dev/null and b/docs/apps/images/app-quickstart.png differ diff --git a/docs/apps/images/app-showcase.png b/docs/apps/images/app-showcase.png new file mode 100644 index 000000000..c03294bdb Binary files /dev/null and b/docs/apps/images/app-showcase.png differ diff --git a/docs/apps/images/dev-app.png b/docs/apps/images/dev-app.png new file mode 100644 index 000000000..fdb05d69e Binary files /dev/null and b/docs/apps/images/dev-app.png differ diff --git a/docs/apps/interactive-apps.mdx b/docs/apps/interactive-apps.mdx new file mode 100644 index 000000000..fb2963114 --- /dev/null +++ b/docs/apps/interactive-apps.mdx @@ -0,0 +1,538 @@ +--- +title: FastMCPApp +sidebarTitle: FastMCPApp +description: Managed tool binding, visibility, and composition for apps with heavy server interaction. +icon: puzzle-piece +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + + +[Prefab](https://prefab.prefect.io) is in early, active development — its API changes frequently and breaking changes can occur with any release. Always pin `prefab-ui` to a specific version in your dependencies. + + +Any [Prefab app](/apps/prefab) can call server tools — there's nothing stopping you from using `CallTool("tool_name")` in a regular `@mcp.tool(app=True)`. But once you have multiple backend tools, the management overhead adds up: Which tools should the model see vs. only the UI? What happens to string-based tool references when servers are composed under namespaces? How do you keep things wired correctly as the app grows? + +`FastMCPApp` is a class that solves these problems. It gives you two decorators that work together: + +- **`@app.ui()`** — entry-point tools the model calls to open the app. These return a Prefab UI. +- **`@app.tool()`** — backend tools the UI calls via `CallTool`. These do the work. + +Backend tools get globally stable identifiers that survive namespacing. Visibility is managed automatically — the model sees entry points, the UI sees backends. And `CallTool` accepts function references instead of strings, so references are refactorable and composition-safe. + +## Your First Interactive App + +Here's a minimal app with a form that saves data: + +```python +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.actions.mcp import CallTool +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, Button, Column, ForEach, Form, + Heading, Input, Row, Separator, Text, +) +from prefab_ui.rx import RESULT +from fastmcp import FastMCP, FastMCPApp + +app = FastMCPApp("Notes") + +notes_db: list[dict] = [] + + +@app.tool() +def add_note(title: str, body: str) -> list[dict]: + """Save a note and return all notes.""" + notes_db.append({"title": title, "body": body}) + return list(notes_db) + + +@app.ui() +def notes_app() -> PrefabApp: + """Open the notes app.""" + with Column(gap=6, css_class="p-6") as view: + Heading("Notes") + + with ForEach("notes") as note: + with Row(gap=2, align="center"): + Text(note.title, css_class="font-semibold") + Badge(note.body) + + Separator() + + with Form( + on_submit=CallTool( + "add_note", + on_success=[ + SetState("notes", RESULT), + ShowToast("Note saved!", variant="success"), + ], + on_error=ShowToast("Failed to save", variant="error"), + ) + ): + Input(name="title", label="Title", required=True) + Input(name="body", label="Body", required=True) + Button("Add Note") + + return PrefabApp(view=view, state={"notes": list(notes_db)}) + + +mcp = FastMCP("Notes Server", providers=[app]) +``` + +When the model calls `notes_app`, the user sees a form. Submitting it calls `add_note` on the server, updates the state with the result, and shows a toast — all without leaving the UI. + +Let's break down the key concepts. + +## Entry Points: @app.ui() + +Entry points are what the model sees and calls to open your app. They return a Prefab UI, just like display tools: + +```python +@app.ui() +def dashboard() -> PrefabApp: + """The model calls this to open the dashboard.""" + with Column(gap=4, css_class="p-6") as view: + Heading("Dashboard") + # ... build UI ... + return PrefabApp(view=view) +``` + +Entry points default to `visibility=["model"]` — they show up in the tool list for the LLM but aren't callable from within the app UI. They support the same options as `@mcp.tool`: `name`, `description`, `title`, `tags`, `icons`, `auth`, and `timeout`. + +```python +@app.ui(title="Contact Manager", description="Open the contact management interface") +def contact_manager() -> PrefabApp: + ... +``` + +## Backend Tools: @app.tool() + +Backend tools do the work. The UI calls them via `CallTool`; they run on the server and return data: + +```python +@app.tool() +def save_contact(name: str, email: str) -> list[dict]: + """Save a contact and return the updated list.""" + db.append({"name": name, "email": email}) + return list(db) +``` + +By default, backend tools are only visible to the app UI (`visibility=["app"]`). The model doesn't see them in the tool list. If you want a tool callable by both the model and the UI, pass `model=True`: + +```python +@app.tool(model=True) +def list_contacts() -> list[dict]: + """Both the model and the UI can call this.""" + return list(db) +``` + +Backend tools support `name`, `description`, `auth`, and `timeout`: + +```python +@app.tool(description="Search contacts by name or email", timeout=10.0) +def search(query: str) -> list[dict]: + ... +``` + +## Connecting UI to Backend: CallTool + +`CallTool` is the bridge between the UI and the server. Pass the name of a backend tool registered with `@app.tool()`: + +```python +from prefab_ui.actions.mcp import CallTool + +# Reference a backend tool by name +CallTool("save_contact", arguments={"name": "Alice", "email": "alice@example.com"}) + +# Arguments can reference state with Rx +from prefab_ui.rx import STATE + +CallTool("search", arguments={"query": STATE.search_term}) +``` + +FastMCPApp resolves the name to the tool's stable global key automatically, so `CallTool("save_contact")` keeps working even when the server is mounted under a namespace. + +You can also pass the function directly — `CallTool(save_contact)` — which can be convenient when the tool is defined in the same file. Both forms resolve identically. + +### Handling Results + +Server calls are asynchronous. Use `on_success` and `on_error` callbacks to handle outcomes: + +```python +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.rx import RESULT + +CallTool( + "save_contact", + on_success=[ + SetState("contacts", RESULT), + ShowToast("Saved!", variant="success"), + ], + on_error=ShowToast("Something went wrong", variant="error"), +) +``` + +`RESULT` is a reactive reference to the value the tool returned — available inside `on_success` callbacks. Similarly, `ERROR` (from `prefab_ui.rx`) is available inside `on_error`. + +Callbacks can be a single action or a list of actions. They execute in order, and an error in any action short-circuits the rest. + +### result_key Shorthand + +When a tool returns data that should replace a state key, `result_key` is a convenient shorthand for `on_success=SetState(key, RESULT)`: + +```python +CallTool("list_contacts", result_key="contacts") + +# equivalent to: +CallTool( + "list_contacts", + on_success=SetState("contacts", RESULT), +) +``` + +## Actions + +`CallTool` is one of several actions available in Prefab. Actions are events attached to component handlers like `on_click`, `on_submit`, and `on_change`. + +### Client Actions + +These run instantly in the browser — no server round-trip: + +```python +from prefab_ui.actions import SetState, ToggleState, AppendState, PopState, ShowToast + +# Set a value +SetState("count", 42) + +# Toggle a boolean +ToggleState("expanded") + +# Append to a list +AppendState("items", {"name": "New Item"}) + +# Remove by index +PopState("items", 0) + +# Show a notification +ShowToast("Done!", variant="success") +``` + +### Chaining Actions + +Pass a list to execute multiple actions in sequence: + +```python +from prefab_ui.components import Button +from prefab_ui.actions import SetState, ShowToast + +Button( + "Reset", + on_click=[ + SetState("query", ""), + SetState("results", []), + ShowToast("Cleared", variant="default"), + ], +) +``` + +### Loading States + +A common pattern: show a loading indicator while a server call is in flight. + +```python +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.actions.mcp import CallTool +from prefab_ui.components import Button +from prefab_ui.rx import RESULT, Rx + +saving = Rx("saving") + +Button( + saving.then("Saving...", "Save"), + disabled=saving, + on_click=[ + SetState("saving", True), + CallTool( + "save_data", + on_success=[ + SetState("saving", False), + SetState("result", RESULT), + ShowToast("Saved!", variant="success"), + ], + on_error=[ + SetState("saving", False), + ShowToast("Failed", variant="error"), + ], + ), + ], +) + +# Pass state={"saving": False} to PrefabApp when returning +``` + +## Forms + +Forms are the most common way to collect input and send it to the server. When a form submits, all named input values are gathered and passed as arguments to the `CallTool` action. + +### Manual Forms + +Build forms with individual input components: + +```python +from prefab_ui.components import Form, Input, Select, SelectOption, Textarea, Button +from prefab_ui.actions.mcp import CallTool +from prefab_ui.actions import ShowToast + +with Form( + on_submit=CallTool( + "create_ticket", + on_success=ShowToast("Ticket created!", variant="success"), + ) +): + Input(name="title", label="Title", required=True) + with Select(name="priority", label="Priority"): + SelectOption("Low", value="low") + SelectOption("Medium", value="medium") + SelectOption("High", value="high") + SelectOption("Critical", value="critical") + Textarea(name="description", label="Description") + Button("Create Ticket") +``` + +When submitted, the CallTool receives `{"title": "...", "priority": "...", "description": "..."}` as arguments to `create_ticket`. + +### Pydantic Model Forms + +For structured data, `Form.from_model()` generates the entire form from a Pydantic model — inputs, labels, and submit wiring: + +```python +from typing import Literal + +from pydantic import BaseModel, Field +from prefab_ui.components import Column, Heading, Form +from prefab_ui.actions.mcp import CallTool +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.app import PrefabApp +from prefab_ui.rx import RESULT + +class BugReport(BaseModel): + title: str = Field(title="Bug Title") + severity: Literal["low", "medium", "high", "critical"] = Field( + title="Severity", default="medium" + ) + description: str = Field(title="Description") + + +@app.ui() +def report_bug() -> PrefabApp: + """File a bug report.""" + with Column(gap=4, css_class="p-6") as view: + Heading("Report a Bug") + Form.from_model( + BugReport, + on_submit=CallTool( + "create_bug", + on_success=ShowToast("Bug filed!", variant="success"), + on_error=ShowToast("Failed to submit", variant="error"), + ), + ) + return PrefabApp(view=view) + + +@app.tool() +def create_bug(data: BugReport) -> str: + """Create a bug report.""" + # save to database... + return f"Created: {data.title}" +``` + +`str` fields become text inputs, `Literal` becomes a select dropdown, `bool` becomes a checkbox. Field titles and defaults are respected. + +## Composition and Namespacing + +The reason `FastMCPApp` exists — and why you'd use it instead of plain `@mcp.tool(app=True)` with `CallTool("tool_name")` — is composition safety. + +When you mount a server under a namespace, tool names get prefixed: + +```python +from fastmcp import FastMCP + +platform = FastMCP("Platform") +platform.mount("contacts", contacts_server) + +# "save_contact" becomes "contacts_save_contact" +``` + +If your UI used `CallTool("save_contact")`, it would break — the tool is now named `contacts_save_contact`. But `CallTool(save_contact)` with a function reference resolves to a globally stable key (like `save_contact-a1b2c3d4`) that bypasses the namespace entirely. + +This is why `FastMCPApp` assigns global keys to backend tools, and why `CallTool` accepts function references. Your app works the same whether it's running standalone or mounted inside a larger platform. + +### Mounting an App + +`FastMCPApp` is a Provider. Add it to a server with `providers=` or `add_provider`: + +```python +from fastmcp import FastMCP, FastMCPApp + +app = FastMCPApp("Contacts") + +@app.ui() +def contact_manager() -> PrefabApp: + ... + +@app.tool() +def save_contact(name: str, email: str) -> dict: + ... + + +# Option 1: providers list +mcp = FastMCP("Platform", providers=[app]) + +# Option 2: add_provider +mcp = FastMCP("Platform") +mcp.add_provider(app) +``` + +Multiple apps can coexist on the same server: + +```python +mcp = FastMCP("Platform", providers=[contacts_app, inventory_app, billing_app]) +``` + +Each app's backend tools have their own global keys, so there's no collision even if two apps have a tool named `save`. + +### Running Standalone + +For development, `FastMCPApp` has a convenience `run()` method that wraps itself in a temporary `FastMCP` server: + +```python +app = FastMCPApp("Contacts") +# ... register tools ... + +if __name__ == "__main__": + app.run() +``` + +## Complete Example: Contact Manager + +This pulls together everything — entry points, backend tools, callable references, forms (both manual and Pydantic), state management, and actions: + +```python expandable +from __future__ import annotations + +from typing import Literal + +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.actions.mcp import CallTool +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, Button, Column, ForEach, Form, + Heading, Input, Muted, Row, Separator, Text, +) +from prefab_ui.rx import RESULT, Rx +from pydantic import BaseModel, Field +from fastmcp import FastMCP, FastMCPApp + +# Data + +contacts_db: list[dict] = [ + {"name": "Arthur Dent", "email": "arthur@earth.com", "category": "Customer"}, + {"name": "Ford Prefect", "email": "ford@betelgeuse.org", "category": "Partner"}, +] + + +class ContactModel(BaseModel): + name: str = Field(title="Full Name", min_length=1) + email: str = Field(title="Email") + category: Literal["Customer", "Vendor", "Partner", "Other"] = "Other" + + +# App + +app = FastMCPApp("Contacts") + + +@app.tool() +def save_contact(data: ContactModel) -> list[dict]: + """Save a new contact and return the updated list.""" + contacts_db.append(data.model_dump()) + return list(contacts_db) + + +@app.tool() +def search_contacts(query: str) -> list[dict]: + """Filter contacts by name or email.""" + q = query.lower() + return [ + c for c in contacts_db + if q in c["name"].lower() or q in c["email"].lower() + ] + + +@app.tool(model=True) +def list_contacts() -> list[dict]: + """Return all contacts. Visible to both the model and the UI.""" + return list(contacts_db) + + +@app.ui() +def contact_manager() -> PrefabApp: + """Open the contact manager.""" + with Column(gap=6, css_class="p-6") as view: + Heading("Contacts") + + with ForEach("contacts") as contact: + with Row(gap=2, align="center"): + Text(contact.name, css_class="font-medium") + Muted(contact.email) + Badge(contact.category) + + Separator() + + Heading("Add Contact", level=3) + Form.from_model( + ContactModel, + on_submit=CallTool( + "save_contact", + on_success=[ + SetState("contacts", RESULT), + ShowToast("Contact saved!", variant="success"), + ], + on_error=ShowToast("Failed to save", variant="error"), + ), + ) + + Separator() + + Heading("Search", level=3) + with Form( + on_submit=CallTool( + "search_contacts", + arguments={"query": Rx("query")}, + on_success=SetState("contacts", RESULT), + ) + ): + Input(name="query", placeholder="Search by name or email...") + Button("Search") + + return PrefabApp(view=view, state={"contacts": list(contacts_db)}) + + +mcp = FastMCP("Contacts Server", providers=[app]) + +if __name__ == "__main__": + mcp.run() +``` + +This example is also available as a runnable server at `examples/apps/contacts/contacts_server.py`. + +## Next Steps + +- **[Prefab Apps](/apps/prefab)** — Components, state, and reactive displays (the building blocks) +- **[Patterns](/apps/patterns)** — Copy-paste examples for common UIs +- **[Development](/apps/development)** — Preview and test app tools locally +- **[Prefab UI Docs](https://prefab.prefect.io)** — Full component reference and advanced patterns diff --git a/docs/apps/low-level.mdx b/docs/apps/low-level.mdx index 944e48498..adda74799 100644 --- a/docs/apps/low-level.mdx +++ b/docs/apps/low-level.mdx @@ -27,7 +27,7 @@ The tool declares which resource to use via `AppConfig`. When the host calls the import json from fastmcp import FastMCP -from fastmcp.server.apps import AppConfig, ResourceCSP +from fastmcp.apps import AppConfig, ResourceCSP mcp = FastMCP("My App Server") @@ -47,7 +47,7 @@ def chart_view() -> str: `AppConfig` controls how a tool or resource participates in the Apps extension. Import it from `fastmcp.server.apps`: ```python -from fastmcp.server.apps import AppConfig +from fastmcp.apps import AppConfig ``` On **tools**, you'll typically set `resource_uri` to point to the UI resource: @@ -145,6 +145,8 @@ The `App` object provides: - **`app.onhostcontextchanged`** — callback for host context changes (e.g., safe area insets) - **`app.getHostContext()`** — get current host context +See the full [ext-apps SDK documentation](https://github.com/modelcontextprotocol/ext-apps) for the complete API reference. + If your HTML loads external scripts, styles, or makes API calls, you need to declare those domains in the CSP configuration. See [Security](#security) below. @@ -158,7 +160,7 @@ Apps run in sandboxed iframes with a deny-by-default Content Security Policy. By If your app needs to load external resources (CDN scripts, API calls, embedded iframes), declare the allowed domains with `ResourceCSP`: ```python -from fastmcp.server.apps import AppConfig, ResourceCSP +from fastmcp.apps import AppConfig, ResourceCSP @mcp.resource( "ui://my-app/view.html", @@ -185,7 +187,7 @@ def my_view() -> str: If your app needs browser capabilities like camera or clipboard access, request them via `ResourcePermissions`: ```python -from fastmcp.server.apps import AppConfig, ResourcePermissions +from fastmcp.apps import AppConfig, ResourcePermissions @mcp.resource( "ui://my-app/view.html", @@ -214,7 +216,7 @@ import qrcode from mcp import types from fastmcp import FastMCP -from fastmcp.server.apps import AppConfig, ResourceCSP +from fastmcp.apps import AppConfig, ResourceCSP from fastmcp.tools import ToolResult mcp = FastMCP("QR Code Server") @@ -290,7 +292,7 @@ Not all hosts support the Apps extension. You can check at runtime using the too ```python from fastmcp import Context -from fastmcp.server.apps import AppConfig, UI_EXTENSION_ID +from fastmcp.apps import AppConfig, UI_EXTENSION_ID @mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) async def my_tool(ctx: Context) -> str: diff --git a/docs/apps/overview.mdx b/docs/apps/overview.mdx index d8dcfc3fd..cb3c7c1ea 100644 --- a/docs/apps/overview.mdx +++ b/docs/apps/overview.mdx @@ -10,67 +10,172 @@ import { VersionBadge } from '/snippets/version-badge.mdx' -MCP Apps let your tools return interactive UIs — rendered in a sandboxed iframe right inside the host client's conversation. Instead of returning plain text, a tool can show a chart, a sortable table, a form, or anything you can build with HTML. +MCP tools normally return text. That works for answers, but not for data the user wants to *explore* — a revenue chart they can hover over, a sortable employee directory, a form that submits structured input. MCP Apps let your tools return interactive UIs rendered right inside the conversation. -FastMCP implements the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) and provides two approaches: + + A Prefab app showing forms, charts, metrics, progress bars, data tables, and interactive controls — all built in Python + -## Prefab Apps (Recommended) +FastMCP builds on the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) with [Prefab](https://prefab.prefect.io), a Python component library that compiles to interactive UIs. You write Python; the user sees charts, tables, forms, and dashboards. + + +The examples throughout the Apps docs require the `apps` extra: + +```bash +pip install "fastmcp[apps]" +``` + +This installs [Prefab UI](https://prefab.prefect.io), the component library used to build app UIs. + + + +FastMCP pins a **minimum** version of `prefab-ui` for compatibility but intentionally does **not** pin an upper bound. Prefab is a rapidly evolving library with frequent breaking changes. If you are deploying to production, you **must** pin `prefab-ui` to a specific version in your own dependencies. Without a pin, a fresh deploy could pull a newer Prefab version that changes component APIs, breaking your app. + + +## Which Approach? + +Most apps start with **[Prefab Apps](/apps/prefab)** — add `app=True` to a tool and return components. That covers charts, tables, dashboards, and client-side interactivity. + +When your UI needs multiple backend tools with managed visibility and composition safety, use **[FastMCPApp](/apps/interactive-apps)**. + +When you want the LLM to design the UI at runtime, use **[Generative UI](/apps/generative)**. + +When you need your own HTML/JS (maps, 3D, video), use **[Custom HTML](/apps/low-level)**. + +FastMCP also includes ready-made **[app providers](/apps/providers/approval)** that add common capabilities with a single `add_provider()` call. + +## Building Apps + +### Prefab Apps - -[Prefab](https://prefab.prefect.io) is in extremely early, active development — its API changes frequently and breaking changes can occur with any release. The FastMCP integration is equally new and under rapid development. These docs are included for users who want to work on the cutting edge; production use is not recommended. Always [pin `prefab-ui` to a specific version](/apps/prefab#getting-started) in your dependencies. - - -[Prefab UI](https://prefab.prefect.io) is a declarative UI framework for Python. You describe layouts, charts, tables, forms, and interactive behaviors using a Python DSL — and the framework compiles them to a JSON protocol that a shared renderer interprets. It started as a component library inside FastMCP and grew into its own framework with [comprehensive documentation](https://prefab.prefect.io). +The quickest way to give a tool a visual UI. Add `app=True` to any tool and return a Prefab component — when the host calls it, the user sees an interactive UI instead of a JSON blob: ```python -from prefab_ui.components import Column, Heading, BarChart, ChartSeries from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading +from prefab_ui.components.charts import BarChart, ChartSeries from fastmcp import FastMCP mcp = FastMCP("Dashboard") + @mcp.tool(app=True) -def sales_chart(year: int) -> PrefabApp: - """Show sales data as an interactive chart.""" - data = get_sales_data(year) +def revenue_chart(year: int) -> PrefabApp: + """Show annual revenue as an interactive bar chart.""" + data = [ + {"quarter": "Q1", "revenue": 42000}, + {"quarter": "Q2", "revenue": 51000}, + {"quarter": "Q3", "revenue": 47000}, + {"quarter": "Q4", "revenue": 63000}, + ] with Column(gap=4, css_class="p-6") as view: - Heading(f"{year} Sales") + Heading(f"{year} Revenue") BarChart( data=data, series=[ChartSeries(data_key="revenue", label="Revenue")], - x_axis="month", + x_axis="quarter", ) return PrefabApp(view=view) ``` -Install with `pip install "fastmcp[apps]"` and see [Prefab Apps](/apps/prefab) for the integration guide. +Prefab apps aren't limited to static displays. Prefab's state system and client-side actions (toggles, tabs, conditionals) all work. You can even call other tools from the UI using `CallTool`. There's no hard wall on what a Prefab app can do. -## Custom HTML Apps +See [Prefab Apps](/apps/prefab) for the full guide. -The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) is an open protocol, and you can use it directly when you need full control. You write your own HTML/CSS/JavaScript and communicate with the host via the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) SDK. +### FastMCPApp -This is the right choice for custom rendering (maps, 3D, video), specific JavaScript frameworks, or capabilities beyond what the component library offers. + + +When your app has a lot of server-side interaction — forms that save data, search that queries a database, multi-step workflows — managing the connection between UI and backend tools gets complicated fast. Which tools should the model see vs. only the UI? What happens to tool references when servers are composed under namespaces? How do you keep `CallTool("save_contact")` working when the tool name changes? + +`FastMCPApp` is a class that solves these problems. It gives you two decorators that work together: + +- **`@app.ui()`** — entry-point tools the model calls to open the app +- **`@app.tool()`** — backend tools the UI calls via `CallTool` + +Backend tools get stable identifiers that survive namespacing, visibility is managed automatically (the model sees entry points, the UI sees backends), and `CallTool` accepts tool names that resolve correctly regardless of how servers are composed: + +```python +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.actions.mcp import CallTool +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Column, Heading, Form, Input, Button, ForEach, Row, Text, Badge, Separator, +) +from prefab_ui.rx import RESULT +from fastmcp import FastMCP, FastMCPApp + +app = FastMCPApp("Contacts") + + +@app.tool() +def save_contact(name: str, email: str) -> list[dict]: + """Save a contact and return the updated list.""" + db.append({"name": name, "email": email}) + return list(db) + + +@app.ui() +def contact_manager() -> PrefabApp: + """Open the contact manager.""" + with Column(gap=6, css_class="p-6") as view: + Heading("Contacts") + with ForEach("contacts") as contact: + with Row(gap=2): + Text(contact.name) + Badge(contact.email) + Separator() + with Form( + on_submit=CallTool( + "save_contact", + on_success=[ + SetState("contacts", RESULT), + ShowToast("Saved!", variant="success"), + ], + ) + ): + Input(name="name", label="Name", required=True) + Input(name="email", label="Email", required=True) + Button("Save") + + return PrefabApp(view=view, state={"contacts": list(db)}) + + +mcp = FastMCP("Server", providers=[app]) +``` + +You *can* build server-interactive UIs without `FastMCPApp` — it's all the same protocol underneath. But once you have multiple tools, composition concerns, or visibility requirements, `FastMCPApp` handles the complexity so you don't have to. + +See [FastMCPApp](/apps/interactive-apps) for the full guide. + +### Generative UI + + + +Instead of pre-building a UI, the LLM can write one from scratch. The `GenerativeUI` provider registers tools that let the model write Prefab Python code, execute it in a sandbox, and render the result — with streaming so the user watches the UI build up in real time. ```python from fastmcp import FastMCP -from fastmcp.server.apps import AppConfig, ResourceCSP +from fastmcp.apps.generative import GenerativeUI -mcp = FastMCP("Custom App") - -@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) -def my_tool() -> str: - return '{"values": [1, 2, 3]}' - -@mcp.resource( - "ui://my-app/view.html", - app=AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"])), -) -def view() -> str: - return "..." +mcp = FastMCP("Prefab Studio") +mcp.add_provider(GenerativeUI()) ``` -See [Custom HTML Apps](/apps/low-level) for the full reference. +See [Generative UI](/apps/generative) for the full guide, or the [provider reference](/apps/providers/generative) for configuration options. + +### Custom HTML + +All the approaches above use [Prefab UI](https://prefab.prefect.io) to build UIs in pure Python. If you need full control — your own HTML, CSS, JavaScript, a specific framework — you can use the [MCP Apps extension directly](/apps/low-level). You write the HTML yourself and communicate with the host via the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) SDK. + +## Previewing Apps Locally + +The `fastmcp dev apps` command launches a browser-based preview for your app tools — no MCP host client needed. See [Development](/apps/development). + +```bash +fastmcp dev apps server.py +``` diff --git a/docs/apps/patterns.mdx b/docs/apps/patterns.mdx index ffc699f89..b0ff80376 100644 --- a/docs/apps/patterns.mdx +++ b/docs/apps/patterns.mdx @@ -1,30 +1,29 @@ --- title: Patterns sidebarTitle: Patterns -description: Charts, tables, forms, and other common tool UIs. +description: Copy-paste examples for common tool UIs. icon: grid-2-plus -tag: SOON +tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' - -[Prefab](https://prefab.prefect.io) is in extremely early, active development — its API changes frequently and breaking changes can occur with any release. The FastMCP integration is equally new and under rapid development. These docs are included for users who want to work on the cutting edge; production use is not recommended. Always pin `prefab-ui` to a specific version in your dependencies. - +Each pattern below is a complete, copy-pasteable tool. They're organized by what you're building — pick the one closest to your use case, paste it, and adapt. -The most common use of Prefab is giving your tools a visual representation — a chart instead of raw numbers, a sortable table instead of a text dump, a status dashboard instead of a list of booleans. Each pattern below is a complete, copy-pasteable tool. +For the full set of available components — layout containers, form controls, overlays, and more — see the [Prefab component reference](https://prefab.prefect.io/docs/components). ## Charts -Prefab includes [bar, line, area, pie, radar, and radial charts](https://prefab.prefect.io/docs/components/charts). They all render client-side with tooltips, legends, and responsive sizing. +Prefab includes [bar, line, area, pie, radar, and radial charts](https://prefab.prefect.io/docs/components/charts). They render client-side with tooltips, legends, and responsive sizing. ### Bar Chart ```python -from prefab_ui.components import Column, Heading, BarChart, ChartSeries from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading +from prefab_ui.components.charts import BarChart, ChartSeries from fastmcp import FastMCP mcp = FastMCP("Charts") @@ -59,11 +58,12 @@ Multiple `ChartSeries` entries plot different data keys. Add `stacked=True` to s ### Area Chart -`LineChart` and `AreaChart` share the same API as `BarChart`, with `curve` for interpolation (`"linear"`, `"smooth"`, `"step"`) and `show_dots` for data points: +`LineChart` and `AreaChart` share the same API as `BarChart`, with `curve` for interpolation and `show_dots` for data points: ```python -from prefab_ui.components import Column, Heading, AreaChart, ChartSeries from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading +from prefab_ui.components.charts import AreaChart, ChartSeries from fastmcp import FastMCP mcp = FastMCP("Charts") @@ -95,11 +95,12 @@ def usage_trend() -> PrefabApp: ### Pie and Donut Charts -`PieChart` uses `data_key` (the numeric value) and `name_key` (the label) instead of series. Set `inner_radius` for a donut: +`PieChart` uses `data_key` (the numeric value) and `name_key` (the label). Set `inner_radius` for a donut: ```python -from prefab_ui.components import Column, Heading, PieChart from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading +from prefab_ui.components.charts import PieChart from fastmcp import FastMCP mcp = FastMCP("Charts") @@ -130,11 +131,11 @@ def ticket_breakdown() -> PrefabApp: ## Data Tables -[DataTable](https://prefab.prefect.io/docs/components/data-display/data-table) provides sortable columns, full-text search, and pagination — all running client-side in the browser. +[DataTable](https://prefab.prefect.io/docs/components/data-display/data-table) provides sortable columns, full-text search, and pagination — all client-side: ```python -from prefab_ui.components import Column, Heading, DataTable, DataTableColumn from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading, DataTable, DataTableColumn from fastmcp import FastMCP mcp = FastMCP("Directory") @@ -161,7 +162,7 @@ def employee_directory() -> PrefabApp: DataTableColumn(key="location", header="Office", sortable=True), ], rows=employees, - searchable=True, + search=True, paginated=True, page_size=15, ) @@ -169,133 +170,16 @@ def employee_directory() -> PrefabApp: return PrefabApp(view=view) ``` -## Forms - -A form collects input, but it needs somewhere to send that input. The [`CallTool`](https://prefab.prefect.io/docs/concepts/actions) action connects a form to a tool on your MCP server — so you need two tools: one that renders the form, and one that handles the submission. - -```python -from prefab_ui.components import ( - Column, Heading, Row, Muted, Badge, Input, Select, - Textarea, Button, Form, ForEach, Separator, -) -from prefab_ui.actions import ShowToast -from prefab_ui.actions.mcp import CallTool -from prefab_ui.app import PrefabApp -from fastmcp import FastMCP - -mcp = FastMCP("Contacts") - -contacts_db: list[dict] = [ - {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Partner"}, -] - - -@mcp.tool(app=True) -def contact_form() -> PrefabApp: - """Show a contact list with a form to add new contacts.""" - with Column(gap=6, css_class="p-6") as view: - Heading("Contacts") - - with ForEach("contacts"): - with Row(gap=2, align="center"): - Muted("{{ name }}") - Muted("{{ email }}") - Badge("{{ category }}") - - Separator() - - with Form( - on_submit=CallTool( - "save_contact", - result_key="contacts", - on_success=ShowToast("Contact saved!", variant="success"), - on_error=ShowToast("{{ $error }}", variant="error"), - ) - ): - Input(name="name", label="Full Name", required=True) - Input(name="email", label="Email", input_type="email", required=True) - Select( - name="category", - label="Category", - options=["Customer", "Vendor", "Partner", "Other"], - ) - Textarea(name="notes", label="Notes", placeholder="Optional notes...") - Button("Save Contact") - - return PrefabApp(view=view, state={"contacts": list(contacts_db)}) - - -@mcp.tool -def save_contact( - name: str, - email: str, - category: str = "Other", - notes: str = "", -) -> list[dict]: - """Save a new contact and return the updated list.""" - contacts_db.append({"name": name, "email": email, "category": category, "notes": notes}) - return list(contacts_db) -``` - -When the user submits the form, the renderer calls `save_contact` on the server with all named input values as arguments. Because `result_key="contacts"` is set, the returned list replaces the `contacts` state — and the `ForEach` re-renders with the new data automatically. - -The `save_contact` tool is a regular MCP tool. The LLM can also call it directly in conversation. Your UI actions and your conversational tools are the same thing. - -### Pydantic Model Forms - -For complex forms, `Form.from_model()` generates the entire form from a Pydantic model — inputs, labels, validation, and submit wiring: - -```python -from typing import Literal - -from pydantic import BaseModel, Field -from prefab_ui.components import Column, Heading, Form -from prefab_ui.actions.mcp import CallTool -from prefab_ui.app import PrefabApp -from fastmcp import FastMCP - -mcp = FastMCP("Bug Tracker") - - -class BugReport(BaseModel): - title: str = Field(title="Bug Title") - severity: Literal["low", "medium", "high", "critical"] = Field( - title="Severity", default="medium" - ) - description: str = Field(title="Description") - steps_to_reproduce: str = Field(title="Steps to Reproduce") - - -@mcp.tool(app=True) -def report_bug() -> PrefabApp: - """Show a bug report form.""" - with Column(gap=4, css_class="p-6") as view: - Heading("Report a Bug") - Form.from_model(BugReport, on_submit=CallTool("create_bug_report")) - - return PrefabApp(view=view) - - -@mcp.tool -def create_bug_report(data: dict) -> str: - """Create a bug report from the form submission.""" - report = BugReport(**data) - # save to database... - return f"Created bug report: {report.title}" -``` - -`str` fields become text inputs, `Literal` becomes a select, `bool` becomes a checkbox. The `on_submit` CallTool receives all field values under a `data` key. - ## Status Displays -Cards, badges, progress bars, and grids combine naturally for dashboards. See the [Prefab layout](https://prefab.prefect.io/docs/concepts/composition) and [container](https://prefab.prefect.io/docs/components/containers) docs for the full set of layout and display components. +Cards, badges, progress bars, and grids combine naturally for dashboards: ```python +from prefab_ui.app import PrefabApp from prefab_ui.components import ( Column, Row, Grid, Heading, Text, Muted, Badge, Card, CardContent, Progress, Separator, ) -from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Monitoring") @@ -319,9 +203,7 @@ def system_status() -> PrefabApp: "All Healthy" if all_ok else "Degraded", variant="success" if all_ok else "destructive", ) - Separator() - with Grid(columns=2, gap=4): for svc in services: with Card(): @@ -338,13 +220,16 @@ def system_status() -> PrefabApp: return PrefabApp(view=view) ``` -## Conditional Content +## Reactive Displays -[`If`, `Elif`, and `Else`](https://prefab.prefect.io/docs/concepts/composition#conditional-rendering) show or hide content based on state. Changes are instant — no server round-trip. +These patterns use state and `Rx()` for client-side interactivity — no server calls needed. + +### Feature Toggles ```python -from prefab_ui.components import Column, Heading, Switch, Separator, Alert, If from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading, Switch, Alert, If, Separator +from prefab_ui.rx import Rx from fastmcp import FastMCP mcp = FastMCP("Flags") @@ -355,47 +240,41 @@ def feature_flags() -> PrefabApp: """Toggle feature flags with live preview.""" with Column(gap=4, css_class="p-6") as view: Heading("Feature Flags") - Switch(name="dark_mode", label="Dark Mode") - Switch(name="beta_features", label="Beta Features") - + Switch(name="beta", label="Beta Features") Separator() - - with If("{{ dark_mode }}"): + with If(Rx("dark_mode")): Alert(title="Dark mode enabled", description="UI will use dark theme.") - with If("{{ beta_features }}"): + with If(Rx("beta")): Alert( title="Beta features active", description="Experimental features are now visible.", variant="warning", ) - return PrefabApp(view=view, state={"dark_mode": False, "beta_features": False}) + return PrefabApp(view=view, state={"dark_mode": False, "beta": False}) ``` -## Tabs - -[Tabs](https://prefab.prefect.io/docs/components/containers/tabs) organize content into switchable views. Switching is client-side — no server round-trip. +### Tabs ```python +from prefab_ui.app import PrefabApp from prefab_ui.components import ( Column, Heading, Text, Muted, Badge, Row, DataTable, DataTableColumn, Tabs, Tab, ForEach, ) -from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Projects") @mcp.tool(app=True) -def project_overview(project_id: str) -> PrefabApp: +def project_overview() -> PrefabApp: """Show project details organized in tabs.""" project = { "name": "FastMCP v3", "description": "Next generation MCP framework with Apps support.", "status": "Active", - "created_at": "2025-01-15", "members": [ {"name": "Alice Chen", "role": "Lead"}, {"name": "Bob Martinez", "role": "Design"}, @@ -408,13 +287,11 @@ def project_overview(project_id: str) -> PrefabApp: with Column(gap=4, css_class="p-6") as view: Heading(project["name"]) - with Tabs(): with Tab("Overview"): Text(project["description"]) with Row(gap=4): Badge(project["status"]) - Muted(f"Created: {project['created_at']}") with Tab("Members"): DataTable( @@ -426,24 +303,22 @@ def project_overview(project_id: str) -> PrefabApp: ) with Tab("Activity"): - with ForEach("activity"): + with ForEach("activity") as item: with Row(gap=2): - Muted("{{ timestamp }}") - Text("{{ message }}") + Muted(item.timestamp) + Text(item.message) return PrefabApp(view=view, state={"activity": project["activity"]}) ``` -## Accordion - -[Accordion](https://prefab.prefect.io/docs/components/containers/accordion) collapses sections to save space. `multiple=True` lets users expand several items at once: +### Accordion ```python +from prefab_ui.app import PrefabApp from prefab_ui.components import ( Column, Heading, Row, Text, Badge, Progress, Accordion, AccordionItem, ) -from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("API Monitor") @@ -461,7 +336,6 @@ def api_health() -> PrefabApp: with Column(gap=4, css_class="p-6") as view: Heading("API Health") - with Accordion(multiple=True): for ep in endpoints: with AccordionItem(ep["path"]): @@ -477,7 +351,81 @@ def api_health() -> PrefabApp: return PrefabApp(view=view) ``` +## Interactive Patterns + +These patterns call server tools. For context on `FastMCPApp`, `@app.tool()`, and `CallTool`, see [FastMCPApp](/apps/interactive-apps). + +### Contact Form + +```python +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.actions.mcp import CallTool +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, Button, Column, ForEach, Form, Heading, + Input, Muted, Row, Select, SelectOption, Separator, Text, Textarea, +) +from prefab_ui.rx import RESULT +from fastmcp import FastMCP, FastMCPApp + +app = FastMCPApp("Contacts") + +contacts_db: list[dict] = [ + {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Partner"}, +] + + +@app.tool() +def save_contact( + name: str, email: str, category: str = "Other", notes: str = "", +) -> list[dict]: + """Save a new contact and return the updated list.""" + contacts_db.append({"name": name, "email": email, "category": category}) + return list(contacts_db) + + +@app.ui() +def contact_form() -> PrefabApp: + """Contact list with an add form.""" + with Column(gap=6, css_class="p-6") as view: + Heading("Contacts") + + with ForEach("contacts") as contact: + with Row(gap=2, align="center"): + Text(contact.name, css_class="font-medium") + Muted(contact.email) + Badge(contact.category) + + Separator() + + with Form( + on_submit=CallTool( + "save_contact", + on_success=[ + SetState("contacts", RESULT), + ShowToast("Contact saved!", variant="success"), + ], + on_error=ShowToast("Failed to save", variant="error"), + ) + ): + Input(name="name", label="Full Name", required=True) + Input(name="email", label="Email", input_type="email", required=True) + with Select(name="category", label="Category"): + SelectOption("Customer", value="Customer") + SelectOption("Vendor", value="Vendor") + SelectOption("Partner", value="Partner") + SelectOption("Other", value="Other") + Textarea(name="notes", label="Notes", placeholder="Optional notes...") + Button("Save Contact") + + return PrefabApp(view=view, state={"contacts": list(contacts_db)}) + + +mcp = FastMCP("Server", providers=[app]) +``` + ## Next Steps -- **[Custom HTML Apps](/apps/low-level)** — When you need your own HTML, CSS, and JavaScript -- **[Prefab UI Docs](https://prefab.prefect.io)** — Components, state, expressions, and actions +- **[FastMCPApp](/apps/interactive-apps)** — Managed tool binding for server-connected UIs +- **[Development](/apps/development)** — Preview app tools locally with `fastmcp dev apps` +- **[Prefab UI Docs](https://prefab.prefect.io)** — Full component reference, layout guides, and more diff --git a/docs/apps/prefab.mdx b/docs/apps/prefab.mdx index 907670d4b..156767869 100644 --- a/docs/apps/prefab.mdx +++ b/docs/apps/prefab.mdx @@ -1,44 +1,33 @@ --- -title: Prefab Apps -sidebarTitle: Prefab Apps -description: Build interactive tool UIs in pure Python — no HTML or JavaScript required. +title: Prefab UI +sidebarTitle: Prefab UI +description: The component library behind FastMCP apps — charts, tables, dashboards, forms, and reactive displays. icon: palette -tag: SOON +tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' - -[Prefab](https://prefab.prefect.io) is in extremely early, active development — its API changes frequently and breaking changes can occur with any release. The FastMCP integration is equally new and under rapid development. These docs are included for users who want to work on the cutting edge; production use is not recommended. Always pin `prefab-ui` to a specific version in your dependencies (see below). - + +[Prefab](https://prefab.prefect.io) is in early, active development — breaking changes can occur with any release. FastMCP pins a minimum version of `prefab-ui` for compatibility but does not pin an upper bound. If you are deploying to production, **pin `prefab-ui` to a specific version** in your own dependencies. + -[Prefab UI](https://prefab.prefect.io) is a declarative UI framework for Python. You describe what your interface should look like — a chart, a table, a form — and return it from your tool. FastMCP takes care of everything else: registering the renderer, wiring the protocol metadata, and delivering the component tree to the host. +[Prefab UI](https://prefab.prefect.io) is the component library behind all FastMCP app features. You describe layouts, charts, tables, and forms in Python, and Prefab compiles them to interactive UIs that render in the host's conversation. -Prefab started as a component library inside FastMCP and grew into a full framework for building interactive applications — with its own state management, reactive expression system, and action model. The [Prefab documentation](https://prefab.prefect.io) covers all of this in depth. This page focuses on the FastMCP integration: what you return from a tool, and what FastMCP does with it. +The simplest way to use it: add `app=True` to a tool and return Prefab components. The host renders an interactive UI instead of text. This works for everything from static charts to reactive dashboards with client-side state — no server round-trips needed. -```bash -pip install "fastmcp[apps]" -``` +For apps that need server interaction (forms, search, CRUD), see [FastMCPApp](/apps/interactive-apps) which adds managed tool binding on top of Prefab UI. For LLM-generated UIs, see [Generative UI](/apps/generative). - -Prefab UI is in active early development and its API changes frequently. We strongly recommend pinning `prefab-ui` to a specific version in your project's dependencies. Installing `fastmcp[apps]` pulls in `prefab-ui` but won't pin it — so a routine `pip install --upgrade` could introduce breaking changes. +## Getting Started -```toml -# pyproject.toml -dependencies = [ - "fastmcp[apps]", - "prefab-ui==0.8.0", # pin to a known working version -] -``` - - -Here's the simplest possible Prefab App — a tool that returns a bar chart: +Here's a tool that returns a bar chart: ```python -from prefab_ui.components import Column, Heading, BarChart, ChartSeries from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading +from prefab_ui.components.charts import BarChart, ChartSeries from fastmcp import FastMCP mcp = FastMCP("Dashboard") @@ -65,67 +54,240 @@ def revenue_chart(year: int) -> PrefabApp: return PrefabApp(view=view) ``` -That's it — you declare a layout using Python's `with` statement, and return it. When the host calls this tool, the user sees an interactive bar chart instead of a JSON blob. The [Patterns](/apps/patterns) page has more examples: area charts, data tables, forms, status dashboards, and more. +The `app=True` flag tells FastMCP this tool returns a UI. When a host calls the tool, the user sees an interactive chart instead of a JSON blob. The [Patterns](/apps/patterns) page has more examples. -## What You Return +## Layout and Components -### Components - -The simplest way to get started. If you're returning a visual representation of data and don't need Prefab's more advanced features like initial state or stylesheets, just return the components directly. FastMCP wraps them in a `PrefabApp` automatically: +Prefab uses Python's `with` statement to express nesting. Containers like `Column`, `Row`, and `Grid` collect their children automatically: ```python -from prefab_ui.components import Column, Heading, Badge -from fastmcp import FastMCP +from prefab_ui.components import ( + Column, Row, Grid, Heading, Text, Muted, Badge, + Card, CardContent, Separator, +) -mcp = FastMCP("Status") - - -@mcp.tool(app=True) -def status_badge() -> Column: - """Show system status.""" - with Column(gap=2) as view: - Heading("All Systems Operational") - Badge("Healthy", variant="success") - return view +with Column(gap=4, css_class="p-6") as view: + Heading("Team Status") + Separator() + with Grid(columns=2, gap=4): + with Card(): + with CardContent(): + Text("API Gateway", css_class="font-medium") + Badge("healthy", variant="success") + with Card(): + with CardContent(): + Text("Cache", css_class="font-medium") + Badge("degraded", variant="destructive") ``` -Want a chart? Return a chart. Want a table? Return a table. FastMCP handles the wiring. - -### PrefabApp - -When you need more control — setting initial state values that components can read and react to, or configuring the rendering engine — return a `PrefabApp` explicitly: +You can also use Python loops to generate components at build time: + +```python +services = [ + {"name": "API", "status": "healthy", "ok": True}, + {"name": "Cache", "status": "degraded", "ok": False}, +] + +with Grid(columns=2, gap=4): + for svc in services: + with Card(): + with CardContent(): + Text(svc["name"]) + Badge( + svc["status"], + variant="success" if svc["ok"] else "destructive", + ) +``` + +Build-time loops produce static content — the data is baked into the component tree at construction time. For dynamic iteration over state that changes at render time, use `ForEach` (covered below). + +The full component library — layout containers, data display, charts, forms, overlays — is documented in the [Prefab component reference](https://prefab.prefect.io/docs/components). + +## State and Reactivity + +Display tools can be interactive without calling the server. The key is **state** — a client-side key-value store that lives in the browser. Components read from state, actions mutate it, and the UI re-renders automatically. + +### Declaring State + +Pass a `state` dict to `PrefabApp` to declare initial state, then use `Rx("key")` to create reactive references: ```python -from prefab_ui.components import Column, Heading, Text, Button, If, Badge -from prefab_ui.actions import ToggleState from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading, Switch, Alert, If +from prefab_ui.rx import Rx from fastmcp import FastMCP -mcp = FastMCP("Demo") +mcp = FastMCP("Flags") @mcp.tool(app=True) -def toggle_demo() -> PrefabApp: - """Interactive toggle with state.""" +def feature_flags() -> PrefabApp: + """Toggle feature flags with live preview.""" with Column(gap=4, css_class="p-6") as view: - Button("Toggle", on_click=ToggleState("show")) - with If("{{ show }}"): - Badge("Visible!", variant="success") + Heading("Feature Flags") + Switch(name="dark_mode", label="Dark Mode") + Switch(name="beta", label="Beta Features") - return PrefabApp(view=view, state={"show": False}) + with If(Rx("dark_mode")): + Alert(title="Dark mode enabled") + with If(Rx("beta")): + Alert(title="Beta features active", variant="warning") + + return PrefabApp(view=view, state={"dark_mode": False, "beta": False}) ``` -The `state` dict provides the initial values. Components reference state with `{{ expression }}` templates. State mutations like `ToggleState` happen entirely in the browser — no server round-trip. The [Prefab state guide](https://prefab.prefect.io/docs/concepts/state) covers this in detail. +Three things to notice here: -### ToolResult +The `state` dict on `PrefabApp` declares the keys and their starting values. `Rx("dark_mode")` creates a reactive reference that compiles to `{{ dark_mode }}` in the wire protocol. -Every tool result has two audiences: the renderer (which displays the UI) and the LLM (which reads the text content to understand what happened). By default, Prefab Apps send `"[Rendered Prefab UI]"` as the text content, which tells the LLM almost nothing. +Interactive components with a `name` prop automatically bind to state. The `Switch(name="dark_mode")` syncs its on/off value to the `dark_mode` state key on every toggle — no event wiring needed. -If you want the LLM to understand the result — so it can reference the data in conversation, summarize it, or decide what to do next — wrap your return in a `ToolResult` with a meaningful `content` string: +`If(Rx("dark_mode"))` shows its children only when the state key is truthy. When the switch flips, the condition re-evaluates instantly in the browser. + +### Reactive References with Rx + +The `Rx` class is how you reference state in component props: + +```python +from prefab_ui.rx import Rx + +count = Rx("count") +``` + +Rx objects support arithmetic, comparisons, and formatting — they compile to expressions the renderer evaluates at render time: ```python -from prefab_ui.components import Column, Heading, BarChart, ChartSeries from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Text, Slider +from prefab_ui.rx import Rx +from fastmcp import FastMCP + +mcp = FastMCP("Calculator") + + +@mcp.tool(app=True) +def tip_calculator() -> PrefabApp: + """Calculate tip with a slider.""" + tip_pct = Rx("tip_pct") + bill = Rx("bill") + + tip_amount = tip_pct / 100 * bill + total = bill + tip_amount + + with Column(gap=4, css_class="p-6") as view: + Slider(name="bill", label="Bill Amount", min=0, max=500, step=0.5) + Slider(name="tip_pct", label="Tip %", min=0, max=50) + Text(f"Tip: {tip_amount.currency()}") + Text(f"Total: {total.currency()}") + + return PrefabApp(view=view, state={"bill": 50.00, "tip_pct": 18}) +``` + +`Rx("tip_pct") / 100 * Rx("bill")` builds a compound expression — it doesn't do the math in Python. The renderer evaluates it live as the sliders move. The `.currency()` pipe formats the result as currency. + +#### Pipes + +Rx objects support formatting pipes that transform values at render time: + +```python +from prefab_ui.rx import Rx + +price = Rx("price") +ratio = Rx("ratio") +name = Rx("name") + +price.currency() # $42.50 +price.currency("EUR") # EUR format +ratio.percent() # 85% +name.upper() # ALICE +name.truncate(10) # alice (or truncated if longer) +``` + +Number pipes include `currency`, `percent`, `number`, `compact`, `round`, and `abs`. String pipes include `upper`, `lower`, and `truncate`. See the [Prefab expression docs](https://prefab.prefect.io/docs/concepts/expressions) for the full list. + +#### Conditionals + +The `.then()` method creates ternary expressions: + +```python +from prefab_ui.rx import Rx + +connected = Rx("connected") + +Badge( + connected.then("Online", "Offline"), + variant=connected.then("success", "destructive"), +) +``` + +### Dynamic Iteration with ForEach + +Python `for` loops generate static content at build time. When you need to iterate over state that can change — a list that grows, items that get filtered — use `ForEach`: + +```python +from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading, ForEach, Row, Text, Badge +from fastmcp import FastMCP + +mcp = FastMCP("Directory") + + +@mcp.tool(app=True) +def team_list() -> PrefabApp: + """Show the current team.""" + members = [ + {"name": "Alice", "role": "Engineering"}, + {"name": "Bob", "role": "Design"}, + ] + + with Column(gap=4, css_class="p-6") as view: + Heading("Team") + with ForEach("members") as member: + with Row(gap=2, align="center"): + Text(member.name, css_class="font-medium") + Badge(member.role) + + return PrefabApp(view=view, state={"members": members}) +``` + +`ForEach("members")` iterates over the `members` state key. The `as member` gives you an Rx proxy scoped to each item, so `member.name` resolves to `{{ $item.name }}` in the wire protocol. If the `members` state changes (e.g., through an action), the list re-renders automatically. + +### Conditional Rendering + +`If`, `Elif`, and `Else` control what's visible based on state: + +```python +from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Select, SelectOption, If, Elif, Else, Text +from prefab_ui.rx import Rx + +tier = Rx("tier") + +with Column(gap=4) as view: + with Select(name="tier", label="Plan"): + SelectOption("Free", value="free") + SelectOption("Pro", value="pro") + SelectOption("Enterprise", value="enterprise") + with If(tier == "enterprise"): + Text("Full access to all features") + with Elif(tier == "pro"): + Text("Advanced features unlocked") + with Else(): + Text("Basic features only") + +# Pass state={"tier": "free"} to PrefabApp when returning +``` + +Changes are instant — switching the dropdown re-evaluates the conditions in the browser. + +## Giving the LLM Context + +By default, Prefab sends `"[Rendered Prefab UI]"` as the text content for the LLM. If the model needs to reason about the data, wrap your return in a `ToolResult` with a meaningful summary: + +```python +from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading +from prefab_ui.components.charts import BarChart, ChartSeries from fastmcp import FastMCP from fastmcp.tools import ToolResult @@ -148,11 +310,28 @@ def sales_overview(year: int) -> ToolResult: ) ``` -The user sees the chart. The LLM sees `"Total revenue for 2025: $203,000 across 4 quarters"` and can reason about it. +The user sees the chart. The LLM sees the summary string. -## Type Inference +## Advanced -If your tool's return type annotation is a Prefab type — `PrefabApp`, `Component`, or their `Optional` variants — FastMCP detects this and enables app rendering automatically: + +`app=True` auto-wires the Prefab renderer with default CSP settings. If your app loads external resources — embedding iframes, fetching from APIs, loading scripts — use `PrefabAppConfig` to add the required domains: + +```python +from fastmcp.apps import PrefabAppConfig, ResourceCSP + +@mcp.tool(app=PrefabAppConfig( + csp=ResourceCSP(frame_domains=["https://example.com"]), +)) +def dashboard_with_embed() -> PrefabApp: + ... +``` + +`PrefabAppConfig()` with no arguments is equivalent to `app=True`. It auto-sets the renderer URI and merges the renderer's CSP with any additional domains you provide. + + + +If your return type annotation is a Prefab type — `PrefabApp`, `Component`, or unions containing them — FastMCP enables app rendering automatically, even without `app=True`: ```python @mcp.tool @@ -160,24 +339,14 @@ def greet(name: str) -> PrefabApp: return PrefabApp(view=Heading(f"Hello, {name}!")) ``` -This is equivalent to `@mcp.tool(app=True)`. Explicit `app=True` is recommended for clarity, and is required when the return type doesn't reveal a Prefab type (e.g., `-> ToolResult`). +Explicit `app=True` is recommended for clarity. + -## How It Works - -Behind the scenes, when a tool returns a Prefab component or `PrefabApp`, FastMCP: - -1. **Registers a shared renderer** — a `ui://prefab/renderer.html` resource containing the JavaScript rendering engine, fetched once by the host and reused across all your Prefab tools. -2. **Wires the tool metadata** — so the host knows to load the renderer iframe when displaying the tool result. -3. **Serializes the component tree** — your Python components become `structuredContent` on the tool result, which the renderer interprets and displays. - -None of this requires any configuration. The `app=True` flag (or type inference) is the only thing you need. - -## Mixing with Custom HTML Apps - -Prefab tools and [custom HTML tools](/apps/low-level) coexist in the same server. Prefab tools share a single renderer resource; custom tools point to their own. Both use the same MCP Apps protocol: + +Prefab tools and [custom HTML tools](/apps/low-level) coexist on the same server: ```python -from fastmcp.server.apps import AppConfig +from fastmcp.apps import AppConfig @mcp.tool(app=True) def team_directory() -> PrefabApp: @@ -187,9 +356,11 @@ def team_directory() -> PrefabApp: def map_view() -> str: ... ``` + ## Next Steps -- **[Patterns](/apps/patterns)** — Charts, tables, forms, and other common tool UIs -- **[Custom HTML Apps](/apps/low-level)** — When you need your own HTML, CSS, and JavaScript -- **[Prefab UI Docs](https://prefab.prefect.io)** — Components, state, expressions, and actions +- **[FastMCPApp](/apps/interactive-apps)** — Managed tool binding for apps with heavy server interaction +- **[Patterns](/apps/patterns)** — Charts, tables, dashboards, and other common examples +- **[Development](/apps/development)** — Preview app tools locally with `fastmcp dev apps` +- **[Prefab UI Docs](https://prefab.prefect.io)** — Full component reference, advanced state patterns, and more diff --git a/docs/apps/providers/approval.mdx b/docs/apps/providers/approval.mdx new file mode 100644 index 000000000..15b683d15 --- /dev/null +++ b/docs/apps/providers/approval.mdx @@ -0,0 +1,80 @@ +--- +title: Approval +sidebarTitle: Approval +description: Human-in-the-loop approval gates for agent actions +icon: shield-check +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`Approval` adds a human-in-the-loop confirmation step to any server. The LLM presents what it's about to do, the user approves or rejects via buttons, and the decision flows back into the conversation as a message. + + + The Approval provider shown in Goose, with a payment confirmation card and Approve/Cancel buttons + + +```python +from fastmcp import FastMCP +from fastmcp.apps.approval import Approval + +mcp = FastMCP("My Server") +mcp.add_provider(Approval()) +``` + +This registers a single tool: + +| Tool | Visibility | Purpose | +|------|-----------|---------| +| `request_approval` | Model | Shows an approval card, sends the user's decision back as a message | + +The LLM calls `request_approval` with a summary (and optional details) whenever it's about to take a significant action. The user sees a card with Approve and Reject buttons. Clicking either sends a message back into the conversation via `SendMessage`, which triggers the LLM's next turn. + +The message looks like it came from the user: + +``` +"Deploy v3.2 to production" — I selected: Approve +``` + + +Approval is an advisory gate, not an enforcement mechanism. The conversation isn't blocked while the card is open — the user can keep typing, and a determined LLM could proceed without waiting. Think of it as a strong UX signal that encourages confirmation, not a security boundary. For hard enforcement, implement approval logic server-side in your tool implementations. + + +## Configuration + +The constructor sets defaults; the LLM can override all of these per-call via tool arguments. + +```python +Approval( + name="Approval", # App name + title="Approval Required", # Card heading + approve_text="Approve", # Approve button label + reject_text="Reject", # Reject button label + approve_variant="default", # "default", "destructive", "success", "info" + reject_variant="outline", # same options plus "outline" +) +``` + +The LLM can customize each invocation: + +```python +request_approval( + summary="Delete 47 files from /tmp", + details="This cannot be undone.", + title="Destructive Action", + approve_text="Delete", + approve_variant="destructive", + reject_text="Keep files", +) +``` + +## How It Works + +When the user clicks a button, two things happen: + +1. `SendMessage` pushes the decision into the conversation as a user message +2. `SetState("decided", True)` replaces the buttons with "Response sent." + +The tool description instructs the LLM to stop and wait for the "I selected:" message before proceeding. If approved, it continues. If rejected, it acknowledges and asks how to proceed. diff --git a/docs/apps/providers/choice.mdx b/docs/apps/providers/choice.mdx new file mode 100644 index 000000000..71672a95c --- /dev/null +++ b/docs/apps/providers/choice.mdx @@ -0,0 +1,72 @@ +--- +title: Choice +sidebarTitle: Choice +description: Present clickable options instead of free-text responses +icon: list-check +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`Choice` lets the LLM present a set of options as clickable buttons instead of asking the user to type a response. The selection flows back into the conversation as a message, giving the LLM clean structured input. + + + The Choice provider shown in Goose, with four lunch options as clickable buttons + + +```python +from fastmcp import FastMCP +from fastmcp.apps.choice import Choice + +mcp = FastMCP("My Server") +mcp.add_provider(Choice()) +``` + +This registers a single tool: + +| Tool | Visibility | Purpose | +|------|-----------|---------| +| `choose` | Model | Shows a card with clickable options, sends the selection back as a message | + +The LLM calls `choose` with a prompt and a list of options. The user sees a card with one button per option. Clicking one sends a message back into the conversation: + +``` +"Which deployment strategy?" — I selected: Blue-green +``` + + +This is an advisory interaction, not an enforcement mechanism. The conversation isn't blocked while the card is open — the user can keep typing, and the LLM could proceed without waiting. The tool description instructs the LLM to stop and wait for the "I selected:" response, but for hard enforcement, implement selection logic server-side. + + +## Configuration + +The constructor sets defaults; the LLM can override `title` per-call. + +```python +Choice( + name="Choice", # App name + title="Choose an Option", # Default card heading + variant="outline", # Button style for all options +) +``` + +The LLM provides the options per-call: + +```python +choose( + prompt="What should we have for lunch?", + options=["Pizza", "Tacos", "Ramen", "Salad"], + title="The Important Questions", +) +``` + +## How It Works + +Each option renders as a full-width button in a vertical stack. When the user clicks one: + +1. `SendMessage` pushes the selection into the conversation as a user message +2. `SetState("decided", True)` replaces the buttons with "Response sent." + +The tool description instructs the LLM to stop and wait for the "I selected:" message before proceeding with whatever the user chose. diff --git a/docs/apps/providers/file-upload.mdx b/docs/apps/providers/file-upload.mdx new file mode 100644 index 000000000..13cf2402e --- /dev/null +++ b/docs/apps/providers/file-upload.mdx @@ -0,0 +1,129 @@ +--- +title: File Upload +sidebarTitle: File Upload +description: Drag-and-drop file upload for any MCP server +icon: upload +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`FileUpload` adds drag-and-drop file upload to any server. Users upload files through an interactive UI, bypassing the LLM context window entirely. The LLM can then list and read uploaded files through model-visible tools. + + + The FileUpload provider shown in Goose, with a drag-and-drop zone for uploading files + + +```python +from fastmcp import FastMCP +from fastmcp.apps.file_upload import FileUpload + +mcp = FastMCP("My Server") +mcp.add_provider(FileUpload()) +``` + +This registers four tools: + +| Tool | Visibility | Purpose | +|------|-----------|---------| +| `file_manager` | Model | Opens the drag-and-drop upload UI | +| `store_files` | App only | Called by the UI when the user clicks Upload | +| `list_files` | Model | Returns metadata for all uploaded files | +| `read_file` | Model | Returns a file's contents by name | + +The LLM sees `file_manager`, `list_files`, and `read_file`. It calls `file_manager` to show the upload interface, then uses `list_files` and `read_file` to work with whatever the user uploaded. `store_files` is app-only — the UI calls it directly and the LLM never needs to know about it. + +## Configuration + +```python +FileUpload( + name="Files", # App name (used in tool routing) + max_file_size=10 * 1024 * 1024, # 10 MB default, enforced server-side + title="File Upload", # Heading shown in the UI + description="Drop files to...", # Description text below the heading + drop_label="Drop files here", # Label inside the drop zone +) +``` + +The `max_file_size` limit is enforced both in the UI (the DropZone rejects oversized files) and on the server (the `store_files` tool validates before calling `on_store`). + +## Storage Scoping + +By default, files are stored in memory and scoped by MCP session ID. Each session gets its own isolated file store — files uploaded in one conversation aren't visible in another. + +This works with **stdio**, **SSE**, and **stateful HTTP** transports, where sessions persist across requests. + + +In **stateless HTTP** mode, each request creates a new session object with a new ID. Files stored during one request (e.g. the UI upload) will be invisible to the next request (e.g. the LLM calling `list_files`). You **must** override `_get_scope_key` to use a stable identifier like a user ID from your auth token. + + +For stateless deployments, override `_get_scope_key` to return a stable identifier. For example, to scope files by authenticated user: + +```python +from fastmcp.apps.file_upload import FileUpload + +class UserScopedUpload(FileUpload): + def _get_scope_key(self, ctx): + return ctx.access_token["sub"] +``` + +For process-wide shared storage (all users see all files): + +```python +class SharedUpload(FileUpload): + def _get_scope_key(self, ctx): + return "__shared__" +``` + +## Custom Storage + +The default implementation stores files in memory for the lifetime of the server process. For persistent storage, subclass `FileUpload` and override three methods. Each receives the current `Context`, giving you access to session IDs, auth tokens, and request metadata for partitioning and authorization. + +```python +import base64 + +from fastmcp.apps.file_upload import FileUpload + +class S3Upload(FileUpload): + def on_store(self, files, ctx): + user_id = ctx.access_token["sub"] + for f in files: + s3.put_object( + Bucket="uploads", + Key=f"{user_id}/{f['name']}", + Body=base64.b64decode(f["data"]), + ) + return self.on_list(ctx) + + def on_list(self, ctx): + user_id = ctx.access_token["sub"] + objects = s3.list_objects(Bucket="uploads", Prefix=f"{user_id}/") + return [ + { + "name": obj["Key"].split("/", 1)[1], + "type": "application/octet-stream", + "size": obj["Size"], + "size_display": f"{obj['Size']} B", + "uploaded_at": obj["LastModified"].isoformat(), + } + for obj in objects.get("Contents", []) + ] + + def on_read(self, name, ctx): + user_id = ctx.access_token["sub"] + obj = s3.get_object(Bucket="uploads", Key=f"{user_id}/{name}") + content = obj["Body"].read() + return { + "name": name, + "size": obj["ContentLength"], + "type": obj["ContentType"], + "uploaded_at": obj["LastModified"].isoformat(), + "content": content.decode("utf-8"), + } +``` + +Each file dict passed to `on_store` contains `name`, `size`, `type`, and `data` (base64-encoded content). The return value from `on_store` and `on_list` should be a list of summary dicts with `name`, `type`, `size`, `size_display`, and `uploaded_at` fields — these populate the file list in the UI. + +`on_read` returns a dict with file metadata and either `content` (decoded text) or `content_base64` (a base64 preview for binary files). diff --git a/docs/apps/providers/form.mdx b/docs/apps/providers/form.mdx new file mode 100644 index 000000000..ca598f33f --- /dev/null +++ b/docs/apps/providers/form.mdx @@ -0,0 +1,105 @@ +--- +title: Form Input +sidebarTitle: Form Input +description: Collect structured data from users via Pydantic models +icon: rectangle-list +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`FormInput` generates a validated form from a Pydantic model. The user fills it out, and the submission is validated against the model before being returned. Structured elicitation that can't be hallucinated. + + + The FormInput provider shown in Goose, with a bug report form + + +```python +from typing import Literal + +from pydantic import BaseModel, Field +from fastmcp import FastMCP +from fastmcp.apps.form import FormInput + +class BugReport(BaseModel): + title: str = Field(description="Brief summary") + severity: Literal["low", "medium", "high", "critical"] + description: str = Field( + description="Detailed description", + json_schema_extra={"ui": {"type": "textarea"}}, + ) + +mcp = FastMCP("My Server") +mcp.add_provider(FormInput(model=BugReport)) +``` + +This registers two tools: + +| Tool | Visibility | Purpose | +|------|-----------|---------| +| `collect_bugreport` | Model | Opens the form UI | +| `submit_form` | App only | Validates and processes the submission | + +The tool name is derived from the model class name, lowercased: `collect_{modelname}`. So `BugReport` becomes `collect_bugreport`, `ShippingAddress` becomes `collect_shippingaddress`. Use `tool_name` to override if needed. The LLM calls it with a prompt explaining what it needs, and the user gets a form with fields matching the model. + +## Field Mapping + +`FormInput` uses Prefab's `Form.from_model()`, which maps Pydantic types to form components: + +| Python type | Form component | +|------------|---------------| +| `str` | Text input | +| `int`, `float` | Number input | +| `bool` | Checkbox | +| `datetime.date` | Date picker | +| `Literal[...]` | Select dropdown | +| `SecretStr` | Password input | + +Use `Field()` metadata to control labels (`title`), placeholders (`description`), and validation (`min_length`, `max_length`, `ge`, `le`). Use `json_schema_extra={"ui": {"type": "textarea"}}` for multiline text. + +## Callback + +By default, the validated model is returned as JSON. Provide an `on_submit` callback to process the data server-side: + +```python +def save_report(report: BugReport) -> str: + db.insert(report.model_dump()) + return f"Bug #{db.last_id} filed: {report.title}" + +mcp.add_provider(FormInput(model=BugReport, on_submit=save_report)) +``` + +The callback receives a validated model instance and returns a string that becomes the tool result. + +## Configuration + +```python +FormInput( + model=BugReport, # Required: the Pydantic model + name="BugTracker", # App name (default: model name) + title="File a Bug", # Card heading (default: model name) + tool_name="file_bug", # Tool name (default: collect_{model}) + submit_text="Submit Report", # Button label (default: "Submit") + on_submit=save_report, # Optional callback + send_message=True, # Push result as a chat message +) +``` + +Set `send_message=True` to push the result back into the conversation via `SendMessage`, triggering the LLM's next turn. Without it, the result is just the tool return value. + +## Multiple Forms + +Add multiple providers for different models — each gets its own tool: + +```python +mcp = FastMCP( + "My Server", + providers=[ + FormInput(model=ShippingAddress), + FormInput(model=BugReport), + FormInput(model=ContactInfo), + ], +) +``` diff --git a/docs/apps/providers/generative.mdx b/docs/apps/providers/generative.mdx new file mode 100644 index 000000000..a0795c939 --- /dev/null +++ b/docs/apps/providers/generative.mdx @@ -0,0 +1,74 @@ +--- +title: Generative UI +sidebarTitle: Generative UI +description: Let the LLM generate custom UIs at runtime +icon: wand-magic-sparkles +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`GenerativeUI` lets the LLM write Prefab Python code at runtime and render it as a streaming interactive UI. Instead of calling pre-built tools with fixed interfaces, the model creates tailored visualizations for whatever data it's working with. + +```python +from fastmcp import FastMCP +from fastmcp.apps.generative import GenerativeUI + +mcp = FastMCP("My Server") +mcp.add_provider(GenerativeUI()) +``` + +This registers: + +| Component | Type | Purpose | +|-----------|------|---------| +| `generate_prefab_ui` | Tool | Accepts Python code, executes in Pyodide sandbox, renders result | +| `search_prefab_components` | Tool | Lets the LLM discover available Prefab components | +| Generative renderer | Resource | `ui://` resource with browser-side Pyodide for streaming | + +The LLM writes real Python — loops, f-strings, computation — using Prefab's component library (charts, tables, forms, cards, layout primitives). As the model generates tokens, the host streams partial code to the renderer via `ontoolinputpartial`, so the user watches the UI build up in real time. + +## Configuration + +```python +GenerativeUI( + tool_name="generate_prefab_ui", # Rename the generation tool + components_tool_name="search_prefab_components", # Rename the search tool + include_components_tool=True, # Set False to omit the search tool +) +``` + +## What the LLM Sees + +The tool description includes code examples that teach the LLM the Prefab patterns. The LLM calls `generate_prefab_ui` with a `code` argument containing Prefab Python, and optionally a `data` argument to pass in real data from the conversation: + +```python +# The LLM generates something like: +generate_prefab_ui( + code=""" +from prefab_ui.components import Column, Heading +from prefab_ui.components.charts import BarChart, ChartSeries +from prefab_ui.app import PrefabApp + +with PrefabApp() as app: + with Column(gap=4): + Heading("Revenue") + BarChart(data=data, series=[ChartSeries(data_key="revenue")], x_axis="quarter") +""", + data={"data": [{"quarter": "Q1", "revenue": 42000}, ...]} +) +``` + +The component search tool lets the LLM discover what's available before writing code — `search_prefab_components("Chart")` returns matching components with import paths. + +## Requirements + +Requires `fastmcp[apps]` (installs `prefab-ui`). The Pyodide sandbox for server-side validation requires Deno, which installs automatically on first use. The streaming renderer loads Pyodide from CDN in the browser — CSP is configured automatically. + +The sandbox includes the Python standard library and Prefab. External packages (NumPy, pandas, etc.) are not available. + +## Learn More + +The full **[Generative UI guide](/apps/generative)** covers the streaming mechanics in detail, how to pass data, the component search tool, and sandbox limitations. diff --git a/docs/apps/quickstart.mdx b/docs/apps/quickstart.mdx new file mode 100644 index 000000000..91265f884 --- /dev/null +++ b/docs/apps/quickstart.mdx @@ -0,0 +1,208 @@ +--- +title: Quickstart +sidebarTitle: Quickstart +description: Build your first MCP app in under a minute. +icon: rocket +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +MCP tools normally return text. FastMCP apps return interactive UIs rendered directly in the conversation: charts, tables, forms, dashboards. The easiest way to build one is with [Prefab UI](https://prefab.prefect.io), a Python component library designed for exactly this. You describe the UI in Python; Prefab compiles it to something the host can render. + +This tutorial builds a working app from scratch. Here's what you'll have in about a minute: + + + A team directory app with a pie chart and sortable data table, rendered inside a conversation in Goose + + +## Setup + +Install FastMCP with the `apps` extra, which pulls in Prefab UI: + +```bash +pip install "fastmcp[apps]" +``` + +## A Tool That Returns a UI + +When your tool has something to *show* (a table of results, a chart, a status dashboard) you can return an interactive UI instead of text. Build the visualization with Prefab components, return it from your tool, and set `app=True` so FastMCP knows to render it. The user sees a live, interactive widget right in the conversation instead of a wall of JSON. + +Create `server.py`: + +```python server.py expandable +from collections import Counter + +from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Grid, Heading, DataTable, DataTableColumn +from prefab_ui.components.charts import PieChart +from fastmcp import FastMCP + +mcp = FastMCP("My First App") + + +@mcp.tool(app=True) +def team_directory() -> PrefabApp: + """Browse the team directory.""" + members = [ + {"name": "Alice Chen", "role": "Staff Engineer", "office": "San Francisco"}, + {"name": "Bob Martinez", "role": "Lead Designer", "office": "New York"}, + {"name": "Carol Johnson", "role": "Senior Engineer", "office": "London"}, + {"name": "David Kim", "role": "Product Manager", "office": "San Francisco"}, + {"name": "Eva Mueller", "role": "Engineer", "office": "Berlin"}, + {"name": "Frank Lee", "role": "Data Scientist", "office": "San Francisco"}, + {"name": "Grace Park", "role": "Engineering Manager", "office": "New York"}, + ] + + office_counts = [ + {"office": office, "count": count} + for office, count in Counter(m["office"] for m in members).items() + ] + + with PrefabApp() as app: + with Column(gap=4, css_class="p-6"): + Heading("Team Directory") + with Grid(columns=[1, 2], gap=4): + PieChart( + data=office_counts, + data_key="count", + name_key="office", + show_legend=True, + ) + DataTable( + columns=[ + DataTableColumn(key="name", header="Name", sortable=True), + DataTableColumn(key="role", header="Role", sortable=True), + DataTableColumn(key="office", header="Office", sortable=True), + ], + rows=members, + search=True, + ) + + return app +``` + +That `app=True` is doing a lot behind the scenes. It tells FastMCP to set up everything the MCP Apps protocol requires: the renderer resource, the content security policy, the metadata that tells the host "this tool returns a UI." Without it, you'd wire all of that up by hand. With it, you just return Prefab components and FastMCP handles the rest. The host (Claude Desktop, Goose, etc.) loads the result in a sandboxed iframe where the user can sort columns, search, and interact, all client-side with no round-trips to your server. + +The Prefab code itself reads top-to-bottom like a document. `PrefabApp()` is the root container and everything inside its `with` block becomes the app's UI. `Column` arranges children vertically. `Heading` renders a title. `DataTable` takes rows of data and column definitions, and gives you sorting and search for free. The `with` blocks establish parent-child relationships: nesting components inside each other builds the layout tree. + +## Running It + +FastMCP includes a dev server that renders your app tools in a browser, no MCP host needed: + +```bash +fastmcp dev apps server.py +``` + +This opens `http://localhost:8080` where you can pick a tool and see the rendered UI. Try sorting the table columns and typing in the search box. + +## Making It Interactive + +The table above is a static snapshot that renders once from the data your Python code provides. But Prefab apps can also respond to user interaction in real time, without any server round-trips. + +The key concept is **state**: a client-side key-value store that components read from and write to. When the user interacts with a component, it updates state. Other components that reference that state re-render instantly. See the [Prefab state docs](https://prefab.prefect.io/docs/concepts/state) for the full guide. + +Here's the same directory, but now clicking a row shows that person's details in a card: + + + The team directory with a detail card showing after clicking Bob Martinez + + +```python expandable server.py +from collections import Counter + +from prefab_ui.actions import SetState +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Card, CardContent, CardHeader, Column, Grid, H3, Heading, Muted, + Row, DataTable, DataTableColumn, Badge, Small, Text, +) +from prefab_ui.components.charts import PieChart +from prefab_ui.components.control_flow import If +from prefab_ui.rx import Rx, STATE +from fastmcp import FastMCP + +mcp = FastMCP("My First App") + +MEMBERS = [ + {"name": "Alice Chen", "role": "Staff Engineer", "office": "San Francisco", "email": "alice@company.com", "projects": 3}, + {"name": "Bob Martinez", "role": "Lead Designer", "office": "New York", "email": "bob@company.com", "projects": 5}, + {"name": "Carol Johnson", "role": "Senior Engineer", "office": "London", "email": "carol@company.com", "projects": 2}, + {"name": "David Kim", "role": "Product Manager", "office": "San Francisco", "email": "david@company.com", "projects": 7}, + {"name": "Eva Mueller", "role": "Engineer", "office": "Berlin", "email": "eva@company.com", "projects": 1}, + {"name": "Frank Lee", "role": "Data Scientist", "office": "San Francisco", "email": "frank@company.com", "projects": 4}, + {"name": "Grace Park", "role": "Engineering Manager", "office": "New York", "email": "grace@company.com", "projects": 6}, +] + +OFFICE_COUNTS = [ + {"office": office, "count": count} + for office, count in Counter(m["office"] for m in MEMBERS).items() +] + + +@mcp.tool(app=True) +def team_directory() -> PrefabApp: + """Browse the team directory.""" + with PrefabApp(state={"selected": None}) as app: + with Column(gap=4, css_class="p-6"): + Heading("Team Directory") + with Grid(columns=[1, 2], gap=4): + PieChart( + data=OFFICE_COUNTS, + data_key="count", + name_key="office", + show_legend=True, + ) + DataTable( + columns=[ + DataTableColumn(key="name", header="Name", sortable=True), + DataTableColumn(key="role", header="Role", sortable=True), + DataTableColumn(key="office", header="Office", sortable=True), + ], + rows=MEMBERS, + search=True, + on_row_click=SetState("selected", Rx("$event")), + ) + + with If(STATE.selected): + with Card(): + with CardHeader(): + with Row(gap=2, align="center"): + H3(Rx("selected.name")) + Badge(Rx("selected.office")) + with CardContent(): + with Grid(columns=3, gap=4): + with Column(gap=0): + Small("Role") + Text(Rx("selected.role")) + with Column(gap=0): + Small("Email") + Text(Rx("selected.email")) + with Column(gap=0): + Small("Active Projects") + Text(Rx("selected.projects")) + + return app +``` + +Three new ideas here: + +**`SetState` + `on_row_click`** is the interaction. When the user clicks a table row, `SetState("selected", Rx("$event"))` writes the clicked row's data into the `selected` state key. `$event` is a special variable that contains the event payload (in this case, the row dict). + +**`Rx("selected.name")`** reads from state reactively. It doesn't hold a Python value. It compiles to a browser-side expression that re-evaluates live whenever `selected` changes. So `Text(Rx("selected.name"))` always shows the name of whoever was last clicked. + +**`If(STATE.selected)`** conditionally renders the detail card only when something has been selected. Before any click, `selected` is `None` and the card is hidden. + +The `state` dict on `PrefabApp` sets initial values when the app loads. Run `fastmcp dev apps server.py` again and try clicking a row. + +## Next Steps + +You've built a tool that returns an interactive, reactive UI. This pattern covers a huge range of use cases: build a visualization in Prefab, return it from a tool, and the user gets dashboards, charts, data tables, and status displays right in the conversation. + +When you need the UI to talk back to your server (forms that save data, buttons that trigger actions, search that queries a database) you promote the tool to a **[FastMCPApp](/apps/interactive-apps)**. That gives you managed backend tools, automatic visibility control, and stable routing so your UI's button clicks reach the right server-side code. + +- **[Prefab UI](/apps/prefab)** covers the full component library: charts, forms, badges, progress bars, and the [reactive state system](https://prefab.prefect.io/docs/concepts/state) in depth. +- **[FastMCPApp](/apps/interactive-apps)** is the next step when your UI needs to interact with backend logic. +- **[App Providers](/apps/providers/approval)** are ready-made capabilities you can add with a single `add_provider()` call. diff --git a/docs/changelog.mdx b/docs/changelog.mdx index ae9e94fe9..d55783e9f 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -5,6 +5,86 @@ rss: true tag: NEW --- + + +**[v3.1.1: 'Tis But a Patch](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.1.1)** + +Pins `pydantic-monty` below 0.0.8 to fix a breaking change in Monty that affects code mode. Monty 0.0.8 removed the `external_functions` constructor parameter, causing `MontySandboxProvider` to fail. This patch caps the version so existing installs work correctly. + +### Fixes 🐞 +* Pin pydantic-monty below 0.0.8 to fix code mode by [@jlowin](https://github.com/jlowin) in [#3497](https://github.com/PrefectHQ/fastmcp/pull/3497) + +**Full Changelog**: [v3.1.0...v3.1.1](https://github.com/PrefectHQ/fastmcp/compare/v3.1.0...v3.1.1) + + + + + +**[v3.1.0: Code to Joy](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.1.0)** + +FastMCP 3.1 is the Code Mode release. The 3.0 architecture introduced providers and transforms as the extensibility layer — 3.1 puts that architecture to work, shipping the most requested capability since launch: servers that can find and execute code on behalf of agents, without requiring clients to know what tools exist. + +### New Features 🎉 +* feat: Search transforms for tool discovery by [@jlowin](https://github.com/jlowin) in [#3154](https://github.com/PrefectHQ/fastmcp/pull/3154) +* Add experimental CodeMode transform by [@aaazzam](https://github.com/aaazzam) in [#3297](https://github.com/PrefectHQ/fastmcp/pull/3297) +* Add Prefab Apps integration for MCP tool UIs by [@jlowin](https://github.com/jlowin) in [#3316](https://github.com/PrefectHQ/fastmcp/pull/3316) +### Enhancements 🔧 +* Lazy-load heavy imports to reduce import time by [@jlowin](https://github.com/jlowin) in [#3295](https://github.com/PrefectHQ/fastmcp/pull/3295) +* Add http_client parameter to all token verifiers for connection pooling by [@jlowin](https://github.com/jlowin) in [#3300](https://github.com/PrefectHQ/fastmcp/pull/3300) +* Add in-memory caching for token introspection results by [@jlowin](https://github.com/jlowin) in [#3298](https://github.com/PrefectHQ/fastmcp/pull/3298) +* Add SessionStart hook to install gh CLI in cloud sessions by [@jlowin](https://github.com/jlowin) in [#3308](https://github.com/PrefectHQ/fastmcp/pull/3308) +* Fix ty 0.0.19 type errors by [@jlowin](https://github.com/jlowin) in [#3310](https://github.com/PrefectHQ/fastmcp/pull/3310) +* Code Mode: Add resource limits to MontySandboxProvider by [@jlowin](https://github.com/jlowin) in [#3326](https://github.com/PrefectHQ/fastmcp/pull/3326) +* Accept transforms as FastMCP init kwarg by [@jlowin](https://github.com/jlowin) in [#3324](https://github.com/PrefectHQ/fastmcp/pull/3324) +* Split large test files to comply with loq line limit by [@jlowin](https://github.com/jlowin) in [#3328](https://github.com/PrefectHQ/fastmcp/pull/3328) +* Add -m/--module flag to `fastmcp run` and `dev inspector` by [@dgenio](https://github.com/dgenio) in [#3331](https://github.com/PrefectHQ/fastmcp/pull/3331) +* Add search_result_serializer hook and serialize_tools_for_output_markdown by [@MagnusS0](https://github.com/MagnusS0) in [#3337](https://github.com/PrefectHQ/fastmcp/pull/3337) +* Add MultiAuth for composing multiple token verification sources by [@jlowin](https://github.com/jlowin) in [#3335](https://github.com/PrefectHQ/fastmcp/pull/3335) +* Adds PropelAuth as an AuthProvider by [@andrew-propelauth](https://github.com/andrew-propelauth) in [#3358](https://github.com/PrefectHQ/fastmcp/pull/3358) +* Replace vendored DI with uncalled-for by [@chrisguidry](https://github.com/chrisguidry) in [#3301](https://github.com/PrefectHQ/fastmcp/pull/3301) +* Decompose CodeMode into composable discovery tools by [@jlowin](https://github.com/jlowin) in [#3354](https://github.com/PrefectHQ/fastmcp/pull/3354) +* feat(contrib): auto-sync MCPMixin decorators with from_function signatures by [@AnkeshThakur](https://github.com/AnkeshThakur) in [#3323](https://github.com/PrefectHQ/fastmcp/pull/3323) +* Add Google GenAI Sampling Handler by [@strawgate](https://github.com/strawgate) in [#2977](https://github.com/PrefectHQ/fastmcp/pull/2977) +* Add ListTools, search limit, and catalog size annotation to CodeMode by [@jlowin](https://github.com/jlowin) in [#3359](https://github.com/PrefectHQ/fastmcp/pull/3359) +* Allow configuring FastMCP transport setting in the same way as other configuration by [@jvdmr](https://github.com/jvdmr) in [#1796](https://github.com/PrefectHQ/fastmcp/pull/1796) +* Add include_unversioned option to VersionFilter by [@yangbaechu](https://github.com/yangbaechu) in [#3349](https://github.com/PrefectHQ/fastmcp/pull/3349) +### Fixes 🐞 +* Fix docs banner pushing nav down by [@jlowin](https://github.com/jlowin) in [#3282](https://github.com/PrefectHQ/fastmcp/pull/3282) +* fix: Replace hardcoded TTL with DEFAULT_TTL_MS - issue #3279 by [@cedric57](https://github.com/cedric57) in [#3280](https://github.com/PrefectHQ/fastmcp/pull/3280) +* fix: stop suppressing server stderr in fastmcp call by [@jlowin](https://github.com/jlowin) in [#3283](https://github.com/PrefectHQ/fastmcp/pull/3283) +* fix: skip max_completion_tokens when maxTokens is None by [@eon01](https://github.com/eon01) in [#3284](https://github.com/PrefectHQ/fastmcp/pull/3284) +* OpenAPI: rewrite $ref under propertyNames and patternProperties in _replace_ref_with_defs; add regression test for dict[StrEnum, Model] by [@manojPal23234](https://github.com/manojPal23234) in [#3306](https://github.com/PrefectHQ/fastmcp/pull/3306) +* Remove stale add_resource() key parameter from docs by [@jlowin](https://github.com/jlowin) in [#3309](https://github.com/PrefectHQ/fastmcp/pull/3309) +* Handle AuthorizationError as exclusion in AuthMiddleware list hooks by [@yangbaechu](https://github.com/yangbaechu) in [#3338](https://github.com/PrefectHQ/fastmcp/pull/3338) +* Fix flaky OpenAPI performance test threshold by [@jlowin](https://github.com/jlowin) in [#3355](https://github.com/PrefectHQ/fastmcp/pull/3355) +* Fix flaky SSE timeout test by [@jlowin](https://github.com/jlowin) in [#3343](https://github.com/PrefectHQ/fastmcp/pull/3343) +* Remove system role references from docs by [@jlowin](https://github.com/jlowin) in [#3356](https://github.com/PrefectHQ/fastmcp/pull/3356) +* Fix session persistence across tool calls in multi-server MCPConfigTransport by [@jer805](https://github.com/jer805) in [#3330](https://github.com/PrefectHQ/fastmcp/pull/3330) +### Docs 📚 +* Add v3.0.2 release notes by [@jlowin](https://github.com/jlowin) in [#3276](https://github.com/PrefectHQ/fastmcp/pull/3276) +* Fix "FastMCP Constructor Parameters" in documentation server.mdx (Remove old parameters & Add new parameter) by [@wangyy04](https://github.com/wangyy04) in [#3317](https://github.com/PrefectHQ/fastmcp/pull/3317) +* Fix stale docs: tag filtering API and missing output_schema param by [@jlowin](https://github.com/jlowin) in [#3322](https://github.com/PrefectHQ/fastmcp/pull/3322) +* Narrate search example clients by [@jlowin](https://github.com/jlowin) in [#3321](https://github.com/PrefectHQ/fastmcp/pull/3321) +* Code Mode: Document resource limits and fix docs formatting by [@jlowin](https://github.com/jlowin) in [#3327](https://github.com/PrefectHQ/fastmcp/pull/3327) +* Add reverse proxy (nginx) section to HTTP deployment docs by [@dgenio](https://github.com/dgenio) in [#3344](https://github.com/PrefectHQ/fastmcp/pull/3344) +* Restructure docs navigation: CLI section, Composition, More by [@jlowin](https://github.com/jlowin) in [#3361](https://github.com/PrefectHQ/fastmcp/pull/3361) +### Other Changes 🦾 +* Don't advertise sampling.tools capability by default by [@jlowin](https://github.com/jlowin) in [#3334](https://github.com/PrefectHQ/fastmcp/pull/3334) + +## New Contributors +* @cedric57 made their first contribution in [#3280](https://github.com/PrefectHQ/fastmcp/pull/3280) +* @eon01 made their first contribution in [#3284](https://github.com/PrefectHQ/fastmcp/pull/3284) +* @manojPal23234 made their first contribution in [#3306](https://github.com/PrefectHQ/fastmcp/pull/3306) +* @wangyy04 made their first contribution in [#3317](https://github.com/PrefectHQ/fastmcp/pull/3317) +* @yangbaechu made their first contribution in [#3338](https://github.com/PrefectHQ/fastmcp/pull/3338) +* @andrew-propelauth made their first contribution in [#3358](https://github.com/PrefectHQ/fastmcp/pull/3358) +* @jer805 made their first contribution in [#3330](https://github.com/PrefectHQ/fastmcp/pull/3330) +* @jvdmr made their first contribution in [#1796](https://github.com/PrefectHQ/fastmcp/pull/1796) + +**Full Changelog**: [v3.0.2...v3.1.0](https://github.com/PrefectHQ/fastmcp/compare/v3.0.2...v3.1.0) + + + **[v3.0.2: Threecovery Mode II](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.2)** @@ -663,6 +743,21 @@ Breaking changes are minimal: for most servers, updating the import statement is + + +**[v2.14.6: $Ref Dead Redemption](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.6)** + +v2.14.4 backported `dereference_refs()` but never wired it into the tool schema pipeline — `$ref` and `$defs` were still sent to MCP clients. Now fixed: `compress_schema()` dereferences at both tool schema creation sites, so schemas are fully inlined before reaching clients. + +### Fixes 🐞 +* Updated deprecation URL for V2 by [@SrzStephen](https://github.com/SrzStephen) in [#3109](https://github.com/PrefectHQ/fastmcp/pull/3109) +* Use MemoryStore for OAuth proxy tests by [@SrzStephen](https://github.com/SrzStephen) in [#3111](https://github.com/PrefectHQ/fastmcp/pull/3111) +* fix: wire up dereference_refs() in tool schema pipeline by [@jlowin](https://github.com/jlowin) in [#3170](https://github.com/PrefectHQ/fastmcp/pull/3170) + +**Full Changelog**: [v2.14.5...v2.14.6](https://github.com/PrefectHQ/fastmcp/compare/v2.14.5...v2.14.6) + + + **[v2.14.5: Sealed Docket](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.5)** diff --git a/docs/cli/install-mcp.mdx b/docs/cli/install-mcp.mdx index bf1b60b36..0171b7854 100644 --- a/docs/cli/install-mcp.mdx +++ b/docs/cli/install-mcp.mdx @@ -65,6 +65,7 @@ See [Server Configuration](/deployment/server-configuration) for the full config | Python | `--python` | Python version (e.g., `3.11`) | | Project | `--project` | Run within a uv project directory | | Requirements | `--with-requirements` | Install from a requirements file | +| Config Path | `--config-path` | Custom path to Claude Desktop config directory (`claude-desktop` only) | ## Examples @@ -92,6 +93,10 @@ fastmcp install cursor server.py --env-file .env fastmcp install claude-desktop server.py \ --python 3.11 \ --with-requirements requirements.txt + +# With custom config path (claude-desktop only) +fastmcp install claude-desktop server.py \ + --config-path "C:\Users\username\AppData\Local\Packages\Claude_xyz\LocalCache\Roaming\Claude" ``` ## Generating MCP JSON diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 2cd4e145d..54783bef0 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -18,6 +18,7 @@ fastmcp --help | Command | What it does | | ------- | ------------ | | [`run`](/cli/running) | Run a server (local file, factory function, remote URL, or config file) | +| [`dev apps`](/cli/running#previewing-apps) | Launch a browser-based preview UI for Prefab App tools | | [`dev inspector`](/cli/running#development-with-the-inspector) | Launch a server inside the MCP Inspector for interactive testing | | [`install`](/cli/install-mcp) | Install a server into Claude Code, Claude Desktop, Cursor, Gemini CLI, or Goose | | [`inspect`](/cli/inspecting) | Print a server's tools, resources, and prompts as a summary or JSON report | diff --git a/docs/cli/running.mdx b/docs/cli/running.mdx index dd976d561..b0cad0a0b 100644 --- a/docs/cli/running.mdx +++ b/docs/cli/running.mdx @@ -96,6 +96,31 @@ By default, `fastmcp run` uses your current Python environment directly. When yo The `--skip-env` flag is useful when you're already inside an activated venv, a Docker container with pre-installed dependencies, or a uv-managed project — it prevents uv from trying to set up another environment layer. +## Previewing Apps + + + +`fastmcp dev apps` launches a browser-based preview UI for servers with [Prefab App tools](/apps/prefab). It starts your MCP server on one port and a local dev UI on another — giving you a live, interactive picker where you can call app tools and see their rendered output without needing a full MCP host client. + +```bash +fastmcp dev apps server.py +fastmcp dev apps server.py:mcp --mcp-port 9000 --dev-port 9090 +``` + +The picker auto-generates a form from each tool's input schema. Submit the form and the result opens in a new tab as a rendered Prefab UI. + +Auto-reload is on by default — save a file and the MCP server restarts automatically. + + +`fastmcp dev apps` requires `fastmcp[apps]` — install with `pip install "fastmcp[apps]"`. + + +| Option | Flag | Description | +| ------ | ---- | ----------- | +| MCP Port | `--mcp-port` | Port for the MCP server (default: `8000`) | +| Dev Port | `--dev-port` | Port for the dev UI (default: `8080`) | +| Auto-Reload | `--reload` / `--no-reload` | Watch for file changes (default: on) | + ## Development with the Inspector `fastmcp dev inspector` launches your server inside the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), a browser-based tool for interactively testing MCP servers. Auto-reload is on by default, so your server restarts when you save changes. diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index 7e3eb03db..efcda3366 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -124,6 +124,38 @@ client = Client( ) ``` +### SSL Verification + +By default, HTTPS connections verify the server's SSL certificate. You can customize this behavior with the `verify` parameter, which accepts the same values as [httpx](https://www.python-httpx.org/advanced/ssl/): + +```python +from fastmcp import Client + +# Disable SSL verification (e.g., for self-signed certs in development) +client = Client("https://dev-server.internal/mcp", verify=False) + +# Use a custom CA bundle +client = Client("https://corp-server.internal/mcp", verify="/path/to/ca-bundle.pem") + +# Use a custom SSL context for full control +import ssl +ctx = ssl.create_default_context() +ctx.load_verify_locations("/path/to/internal-ca.pem") +client = Client("https://corp-server.internal/mcp", verify=ctx) +``` + +The `verify` parameter is also available directly on `StreamableHttpTransport` and `SSETransport`: + +```python +from fastmcp.client.transports import StreamableHttpTransport + +transport = StreamableHttpTransport( + url="https://dev-server.internal/mcp", + verify=False, +) +client = Client(transport) +``` + ### SSE Transport Server-Sent Events transport is maintained for backward compatibility. Use Streamable HTTP for new deployments unless you have specific infrastructure requirements. diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx index e961de9d1..d2078db1b 100644 --- a/docs/deployment/http.mdx +++ b/docs/deployment/http.mdx @@ -115,6 +115,10 @@ async def health_check(request): This health endpoint will be available at `http://localhost:8000/health` and can be used by load balancers, monitoring systems, or deployment platforms to verify your server is running. + +Custom routes are never protected by the server's authentication middleware, even when an `AuthProvider` is configured. This is by design — the primary use case for custom routes is unauthenticated operational endpoints like health checks and readiness probes. If you need authenticated HTTP endpoints alongside your MCP server, [mount it in a FastAPI app](/integrations/fastapi) and use FastAPI's `Depends()` for auth on your routes. + + ### Custom Middleware @@ -625,18 +629,18 @@ You might expect sticky sessions (session affinity) to solve this, but they don' For horizontally scaled deployments, enable stateless HTTP mode. In stateless mode, each request creates a fresh transport context, eliminating the need for session affinity entirely. -**Option 1: Via constructor** +**Option 1: Via `http_app()`** ```python from fastmcp import FastMCP -mcp = FastMCP("My Server", stateless_http=True) +mcp = FastMCP("My Server") @mcp.tool def process(data: str) -> str: return f"Processed: {data}" -app = mcp.http_app() +app = mcp.http_app(stateless_http=True) ``` **Option 2: Via `run()`** diff --git a/docs/docs.json b/docs/docs.json index 69b173496..747899efa 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -12,7 +12,7 @@ "decoration": "gradient" }, "banner": { - "content": "Deploy FastMCP servers for free on [Prefect Horizon](https://www.prefect.io/horizon)" + "content": "Meet [Prefect Horizon](https://prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_banner&utm_content=sitewide_banner), the enterprise MCP gateway built by the team behind FastMCP" }, "colors": { "dark": "#f72585", @@ -140,8 +140,7 @@ "servers/providers/proxy", "servers/providers/skills", "servers/providers/custom" - ], - "tag": "NEW" + ] }, { "collapsed": true, @@ -156,25 +155,30 @@ "servers/transforms/tool-search", "servers/transforms/resources-as-tools", "servers/transforms/prompts-as-tools" - ], - "tag": "NEW" + ] }, { "collapsed": true, - "group": "Authentication", - "icon": "key", + "group": "Auth", + "icon": "shield-check", "pages": [ - "servers/auth/authentication", - "servers/auth/token-verification", - "servers/auth/remote-oauth", - "servers/auth/oauth-proxy", - "servers/auth/oidc-proxy", - "servers/auth/full-oauth-server", - "servers/auth/multi-auth" - ], - "tag": "UPDATED" + { + "collapsed": true, + "group": "Authentication", + "icon": "key", + "pages": [ + "servers/auth/authentication", + "servers/auth/token-verification", + "servers/auth/remote-oauth", + "servers/auth/oauth-proxy", + "servers/auth/oidc-proxy", + "servers/auth/full-oauth-server", + "servers/auth/multi-auth" + ] + }, + "servers/authorization" + ] }, - "servers/authorization", { "collapsed": true, "group": "Deployment", @@ -192,9 +196,44 @@ "group": "Apps", "pages": [ "apps/overview", - "apps/prefab", - "apps/patterns", - "apps/low-level" + "apps/quickstart", + "apps/examples", + { + "collapsed": true, + "group": "Building Apps", + "icon": "hammer", + "pages": [ + "apps/prefab", + "apps/interactive-apps", + "apps/generative", + "apps/patterns" + ], + "tag": "NEW" + }, + { + "collapsed": true, + "group": "Providers", + "icon": "layer-group", + "pages": [ + "apps/providers/approval", + "apps/providers/choice", + "apps/providers/file-upload", + "apps/providers/form", + "apps/providers/generative" + ], + "tag": "NEW" + }, + { + "collapsed": true, + "group": "Advanced", + "icon": "gear", + "pages": [ + "apps/development", + "apps/architecture", + "apps/low-level" + ], + "tag": "NEW" + } ] }, { @@ -315,6 +354,7 @@ { "group": "More", "pages": [ + "more/settings", { "collapsed": true, "group": "Upgrading", @@ -362,10 +402,25 @@ "python-sdk/fastmcp-mcp_config", "python-sdk/fastmcp-settings", "python-sdk/fastmcp-telemetry", + "python-sdk/fastmcp-types", + { + "group": "fastmcp.apps", + "pages": [ + "python-sdk/fastmcp-apps-__init__", + "python-sdk/fastmcp-apps-app", + "python-sdk/fastmcp-apps-approval", + "python-sdk/fastmcp-apps-choice", + "python-sdk/fastmcp-apps-config", + "python-sdk/fastmcp-apps-file_upload", + "python-sdk/fastmcp-apps-form", + "python-sdk/fastmcp-apps-generative" + ] + }, { "group": "fastmcp.cli", "pages": [ "python-sdk/fastmcp-cli-__init__", + "python-sdk/fastmcp-cli-apps_dev", "python-sdk/fastmcp-cli-auth", "python-sdk/fastmcp-cli-cimd", "python-sdk/fastmcp-cli-cli", @@ -475,16 +530,16 @@ "group": "fastmcp.prompts", "pages": [ "python-sdk/fastmcp-prompts-__init__", - "python-sdk/fastmcp-prompts-function_prompt", - "python-sdk/fastmcp-prompts-prompt" + "python-sdk/fastmcp-prompts-base", + "python-sdk/fastmcp-prompts-function_prompt" ] }, { "group": "fastmcp.resources", "pages": [ "python-sdk/fastmcp-resources-__init__", + "python-sdk/fastmcp-resources-base", "python-sdk/fastmcp-resources-function_resource", - "python-sdk/fastmcp-resources-resource", "python-sdk/fastmcp-resources-template", "python-sdk/fastmcp-resources-types" ] @@ -493,6 +548,7 @@ "group": "fastmcp.server", "pages": [ "python-sdk/fastmcp-server-__init__", + "python-sdk/fastmcp-server-app", "python-sdk/fastmcp-server-apps", { "group": "auth", @@ -521,6 +577,7 @@ "python-sdk/fastmcp-server-auth-providers-auth0", "python-sdk/fastmcp-server-auth-providers-aws", "python-sdk/fastmcp-server-auth-providers-azure", + "python-sdk/fastmcp-server-auth-providers-clerk", "python-sdk/fastmcp-server-auth-providers-debug", "python-sdk/fastmcp-server-auth-providers-descope", "python-sdk/fastmcp-server-auth-providers-discord", @@ -684,9 +741,9 @@ "group": "fastmcp.tools", "pages": [ "python-sdk/fastmcp-tools-__init__", + "python-sdk/fastmcp-tools-base", "python-sdk/fastmcp-tools-function_parsing", "python-sdk/fastmcp-tools-function_tool", - "python-sdk/fastmcp-tools-tool", "python-sdk/fastmcp-tools-tool_transform" ] }, @@ -734,6 +791,7 @@ } ] }, + "python-sdk/fastmcp-utilities-mime", { "group": "openapi", "pages": [ @@ -750,6 +808,7 @@ "python-sdk/fastmcp-utilities-skills", "python-sdk/fastmcp-utilities-tests", "python-sdk/fastmcp-utilities-timeout", + "python-sdk/fastmcp-utilities-token_cache", "python-sdk/fastmcp-utilities-types", "python-sdk/fastmcp-utilities-ui", "python-sdk/fastmcp-utilities-version_check", @@ -1050,4 +1109,4 @@ "appearance": "light", "background": "/assets/brand/thumbnail-background-4.jpeg" } -} \ No newline at end of file +} diff --git a/docs/fastmcp-analytics.js b/docs/fastmcp-analytics.js new file mode 100644 index 000000000..07be00534 --- /dev/null +++ b/docs/fastmcp-analytics.js @@ -0,0 +1,257 @@ +(function () { + if (typeof window === "undefined") return; + + // Public browser key for the shared Prefect Amplitude project. + // This is intentionally client-side; the secret key must never ship to the browser. + var AMPLITUDE_API_KEY = "c361ed56e7bdc1a48a38773c40120b39"; + var AMPLITUDE_SCRIPT_URL = + "https://cdn.amplitude.com/libs/analytics-browser-2.8.1-min.js.gz"; + var AMPLITUDE_SERVER_URL = "https://api2.amplitude.com/2/httpapi"; + var PAGE_VIEW_EVENT = "Page View: FastMCP Docs"; + var OUTBOUND_CLICK_EVENT = "Docs Outbound Clicked"; + var SOURCE = "docs"; + var SOURCE_DETAIL = "fastmcp"; + var SURFACE = "fastmcp_docs"; + var DEVICE_ID_PARAM = "deviceId"; + var routeListenersInstalled = false; + var amplitudeInitialized = false; + var lastTrackedUrl = null; + + var PREFECT_DESTINATION_HOSTNAMES = [ + "www.prefect.io", + "prefect.io", + "horizon.prefect.io", + "app.prefect.cloud", + ]; + + var routeChangeCallbacks = []; + + function loadScript(src, onload) { + var script = document.createElement("script"); + script.src = src; + script.async = true; + + if (typeof onload === "function") { + script.addEventListener("load", onload); + } + + document.head.appendChild(script); + return script; + } + + function getAmplitude() { + return window.amplitude || window.amplitudeAnalytics; + } + + function normalizePathname(pathname) { + if (pathname === "/") return pathname; + return pathname.replace(/\/+$/, ""); + } + + function observeRouteChanges(callback) { + routeChangeCallbacks.push(callback); + + if (!routeListenersInstalled) { + var fireCallbacks = function () { + routeChangeCallbacks.forEach(function (cb) { + window.setTimeout(cb, 0); + }); + }; + + var wrapHistoryMethod = function (methodName) { + var original = window.history[methodName]; + window.history[methodName] = function () { + var result = original.apply(this, arguments); + fireCallbacks(); + return result; + }; + }; + + wrapHistoryMethod("pushState"); + wrapHistoryMethod("replaceState"); + window.addEventListener("popstate", fireCallbacks); + window.addEventListener("hashchange", fireCallbacks); + routeListenersInstalled = true; + } + + callback(); + } + + function buildPageViewProperties() { + return { + url: window.location.href, + title: document.title, + referrer: document.referrer || null, + path: normalizePathname(window.location.pathname), + source: SOURCE, + source_detail: SOURCE_DETAIL, + surface: SURFACE, + }; + } + + function trackPageView() { + var amplitude = getAmplitude(); + if (!amplitude || typeof amplitude.track !== "function") { + return; + } + + var url = window.location.href; + if (url === lastTrackedUrl) { + return; + } + + amplitude.track(PAGE_VIEW_EVENT, buildPageViewProperties()); + lastTrackedUrl = url; + } + + function parseUrl(href) { + try { + return new URL(href, window.location.origin); + } catch (error) { + return null; + } + } + + function isPrefectDestination(url) { + return PREFECT_DESTINATION_HOSTNAMES.indexOf(url.hostname) !== -1; + } + + function addDeviceIdToLink(event) { + var amplitude = getAmplitude(); + if (!amplitude || typeof amplitude.getDeviceId !== "function") { + return; + } + + var link = event.currentTarget; + var href = link.getAttribute("href") || ""; + + var url = parseUrl(href); + if (!url || !isPrefectDestination(url)) { + return; + } + + url.searchParams.set(DEVICE_ID_PARAM, amplitude.getDeviceId()); + link.href = url.toString(); + } + + function removeDeviceIdFromLink(event) { + var link = event.currentTarget; + var href = link.getAttribute("href") || ""; + + var url = parseUrl(href); + if (!url || !isPrefectDestination(url)) { + return; + } + + url.searchParams.delete(DEVICE_ID_PARAM); + link.href = url.toString(); + } + + function attachDeviceIdForwarding() { + var elements = document.querySelectorAll("a[href]"); + elements.forEach(function (element) { + if (element.dataset.fastmcpDeviceIdBound === "true") { + return; + } + + var url = parseUrl(element.getAttribute("href") || ""); + if (!url || !isPrefectDestination(url)) { + return; + } + + element.addEventListener("mouseenter", addDeviceIdToLink); + element.addEventListener("mouseleave", removeDeviceIdFromLink); + element.addEventListener("focus", addDeviceIdToLink); + element.addEventListener("blur", removeDeviceIdFromLink); + element.addEventListener("touchstart", addDeviceIdToLink); + element.addEventListener("touchcancel", removeDeviceIdFromLink); + element.dataset.fastmcpDeviceIdBound = "true"; + }); + } + + function trackOutboundClick(event) { + var link = event.target && event.target.closest + ? event.target.closest("a[href]") + : null; + + if (!link) { + return; + } + + var href = link.getAttribute("href"); + if (!href || href[0] === "#") { + return; + } + + var destination; + destination = parseUrl(href); + if (!destination) { + return; + } + + if (destination.hostname === window.location.hostname) { + return; + } + + var amplitude = getAmplitude(); + if (!amplitude || typeof amplitude.track !== "function") { + return; + } + + amplitude.track(OUTBOUND_CLICK_EVENT, { + path: normalizePathname(window.location.pathname), + url: window.location.href, + title: document.title, + source: SOURCE, + source_detail: SOURCE_DETAIL, + surface: SURFACE, + destination: destination.href, + destination_domain: destination.hostname, + link_text: (link.textContent || "").trim().slice(0, 200), + is_prefect_destination: isPrefectDestination(destination), + }); + } + + function initializeAmplitude() { + var amplitude = getAmplitude(); + if ( + amplitudeInitialized || + !amplitude || + typeof amplitude.init !== "function" + ) { + return; + } + + amplitude.init(AMPLITUDE_API_KEY, undefined, { + useBatch: true, + serverUrl: AMPLITUDE_SERVER_URL, + attribution: { + disabled: false, + trackNewCampaigns: true, + trackPageViews: true, + resetSessionOnNewCampaign: true, + }, + defaultTracking: { + pageViews: false, + sessions: false, + formInteractions: true, + fileDownloads: true, + }, + }); + + amplitudeInitialized = true; + observeRouteChanges(trackPageView); + observeRouteChanges(attachDeviceIdForwarding); + } + + function initialize() { + document.addEventListener("click", trackOutboundClick, true); + loadScript(AMPLITUDE_SCRIPT_URL, initializeAmplitude); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialize); + } else { + initialize(); + } +})(); diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index 3f240cec1..5f3f56b38 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -3,7 +3,7 @@ title: Quickstart icon: rocket-launch --- -Welcome! This guide will help you quickly set up FastMCP, run your first MCP server, and deploy a server to Prefect Horizon. +Welcome! This guide will help you quickly set up FastMCP, run your first MCP server, give it a visual UI, and deploy it to Prefect Horizon. If you haven't already installed FastMCP, follow the [installation instructions](/getting-started/installation). @@ -117,6 +117,34 @@ Note that: - We must enter a client context (`async with client:`) before using the client - You can make multiple client calls within the same context +## Give Your Tool a UI + +Tools normally return text, but any tool can return an interactive UI instead. Add `app=True` to your tool decorator and return a [Prefab](https://prefab.prefect.io) component — the host renders it as a chart, table, form, or any other visual element right in the conversation. This requires the `apps` extra (`pip install "fastmcp[apps]"`). + +The `app=True` flag tells FastMCP to wire up the renderer and protocol metadata automatically. The tool still works like any other MCP tool — it receives arguments and returns a result — but the result is a component tree that the host displays visually instead of as plain text. + +```python my_server.py +from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Heading, Text, Badge, Row +from fastmcp import FastMCP + +mcp = FastMCP("My MCP Server") + + +@mcp.tool(app=True) +def greet(name: str) -> PrefabApp: + """Greet someone with a visual card.""" + with Column(gap=4, css_class="p-6") as view: + Heading(f"Hello, {name}!") + with Row(gap=2, align="center"): + Text("Status") + Badge("Greeted", variant="success") + + return PrefabApp(view=view) +``` + +You can preview app tools locally with `fastmcp dev apps my_server.py` — no MCP host required. See the [Apps overview](/apps/overview) for the full guide, including state management, forms, charts, and server-connected interactivity. + ## Deploy to Prefect Horizon [Prefect Horizon](https://horizon.prefect.io) is the enterprise MCP platform built by the FastMCP team at [Prefect](https://www.prefect.io). It provides managed hosting, authentication, access control, and observability for MCP servers. diff --git a/docs/getting-started/upgrading/from-fastmcp-2.mdx b/docs/getting-started/upgrading/from-fastmcp-2.mdx index 47baa5655..f4795fe15 100644 --- a/docs/getting-started/upgrading/from-fastmcp-2.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-2.mdx @@ -77,7 +77,7 @@ BREAKING CHANGES (will crash at import or runtime): 10. DECORATORS: @mcp.tool, @mcp.resource, @mcp.prompt now return the original function, not a component object. Code that accesses .name, .description, or other component attributes on the decorated result will crash with AttributeError. Fix: set FASTMCP_DECORATOR_MODE=object for v2 compat (itself deprecated). -11. OAUTH STORAGE: Default OAuth client storage changed from DiskStore to FileTreeStore due to pickle deserialization vulnerability in diskcache (CVE-2025-69872). Clients using default storage will re-register automatically on first connection. If using DiskStore explicitly, switch to FileTreeStore or add pip install 'py-key-value-aio[disk]'. +11. OAUTH STORAGE: Default OAuth client storage changed from DiskStore to FileTreeStore due to pickle deserialization vulnerability in diskcache (CVE-2025-69872). Clients using default storage will re-register automatically on first connection. If using DiskStore explicitly, switch to FileTreeStore (with key/collection sanitization strategies) or add pip install 'py-key-value-aio[disk]'. 12. REPO MOVE: GitHub repository moved from jlowin/fastmcp to PrefectHQ/fastmcp. Update git remotes and dependency URLs that reference the old location. @@ -126,7 +126,11 @@ The default OAuth client storage has moved from `DiskStore` to `FileTreeStore` t If you were using the default storage (i.e., not passing an explicit `client_storage`), clients will need to re-register on their first connection after upgrading. This happens automatically — no user action required, and it's the same flow that already occurs whenever a server restarts with in-memory storage. -If you were passing a `DiskStore` explicitly, you can either [switch to `FileTreeStore`](/servers/storage-backends) (recommended) or keep using `DiskStore` by adding the dependency yourself: +If you were passing a `DiskStore` explicitly, you can either [switch to `FileTreeStore`](/servers/storage-backends) (recommended) or keep using `DiskStore` by adding the dependency yourself. + + +When switching to `FileTreeStore`, you **must** configure key and collection sanitization strategies. Without them, keys containing special characters (such as URL-based OAuth client IDs) will cause filesystem errors. See the [File Storage](/servers/storage-backends#file-storage) section for the recommended setup. + Keeping `DiskStore` requires `pip install 'py-key-value-aio[disk]'`, which re-introduces the vulnerable `diskcache` package into your dependency tree. diff --git a/docs/getting-started/welcome.mdx b/docs/getting-started/welcome.mdx index 28932284f..a0c17720b 100644 --- a/docs/getting-started/welcome.mdx +++ b/docs/getting-started/welcome.mdx @@ -80,10 +80,16 @@ FastMCP has three pillars: **[Servers](/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](/clients/client)** connect to any server with full protocol support. And **[Apps](/apps/overview)** give your tools interactive UIs rendered directly in the conversation. -Ready to build? Start with the [installation guide](/getting-started/installation) or jump straight to the [quickstart](/getting-started/quickstart). When you're ready to deploy, [Prefect Horizon](https://www.prefect.io/horizon) offers free hosting for FastMCP users. +Ready to build? Start with the [installation guide](/getting-started/installation) or jump straight to the [quickstart](/getting-started/quickstart). FastMCP is made with 💙 by [Prefect](https://www.prefect.io/). +## Run FastMCP in production with Horizon + +FastMCP is how teams build MCP servers. **[Prefect Horizon](https://www.prefect.io/horizon)** is how enterprises run them in production. Register any MCP server behind a managed gateway with SSO, tool-level RBAC, audit logs, and observability. Deploy FastMCP servers and go from PR to preview in 60 seconds, then remix tools from across your registry into use-case-specific, permissioned endpoints. Horizon is everything we've learned about MCP at scale from building the world's most popular MCP framework. Free for individuals, built for teams. + +[Deploy FastMCP with Horizon →](https://www.prefect.io/horizon) + **This documentation reflects FastMCP's `main` branch**, meaning it always reflects the latest development version. Features are generally marked with version badges (e.g. `New in version: 3.0.0`) to indicate when they were introduced. Note that this may include features that are not yet released. diff --git a/docs/more/settings.mdx b/docs/more/settings.mdx new file mode 100644 index 000000000..54bb73f3a --- /dev/null +++ b/docs/more/settings.mdx @@ -0,0 +1,96 @@ +--- +title: Settings +description: Configure FastMCP behavior through environment variables or a .env file. +icon: gear +--- + +FastMCP uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for configuration. Every setting is available as an environment variable with a `FASTMCP_` prefix. Settings are loaded from environment variables and from a `.env` file (see the [Tasks (Docket)](#tasks-docket) section for a caveat about nested settings in `.env` files). + +```bash +# Set via environment +export FASTMCP_LOG_LEVEL=DEBUG +export FASTMCP_PORT=3000 + +# Or use a .env file (loaded automatically) +echo "FASTMCP_LOG_LEVEL=DEBUG" >> .env +``` + +You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` environment variable (defaults to `.env`). Because this controls which file is loaded, it must be set as an environment variable — it cannot be set inside a `.env` file itself. + +## Logging + +| Environment Variable | Type | Default | Description | +|---|---|---|---| +| `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_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. | + +## Transport & HTTP + +These control how the server listens when running with an HTTP transport. + +| Environment Variable | Type | Default | Description | +|---|---|---|---| +| `FASTMCP_TRANSPORT` | `Literal["stdio", "http", "sse", "streamable-http"]` | `stdio` | Default transport. | +| `FASTMCP_HOST` | `str` | `127.0.0.1` | Host to bind to. | +| `FASTMCP_PORT` | `int` | `8000` | Port to bind to. | +| `FASTMCP_SSE_PATH` | `str` | `/sse` | Path for SSE endpoint. | +| `FASTMCP_MESSAGE_PATH` | `str` | `/messages/` | Path for SSE message endpoint. | +| `FASTMCP_STREAMABLE_HTTP_PATH` | `str` | `/mcp` | Path for Streamable HTTP endpoint. | +| `FASTMCP_STATELESS_HTTP` | `bool` | `false` | Enable stateless HTTP mode (new transport per request). Useful for multi-worker deployments. | +| `FASTMCP_JSON_RESPONSE` | `bool` | `false` | Use JSON responses instead of SSE for Streamable HTTP. | +| `FASTMCP_DEBUG` | `bool` | `false` | Enable debug mode. | + +## Error Handling + +| Environment Variable | Type | Default | Description | +|---|---|---|---| +| `FASTMCP_MASK_ERROR_DETAILS` | `bool` | `false` | Mask error details before sending to clients. When enabled, only messages from explicitly raised `ToolError`, `ResourceError`, or `PromptError` are included in responses. | +| `FASTMCP_STRICT_INPUT_VALIDATION` | `bool` | `false` | Strictly validate tool inputs against the JSON schema. When disabled, compatible inputs are coerced (e.g., the string `"10"` becomes the integer `10`). | +| `FASTMCP_MOUNTED_COMPONENTS_RAISE_ON_LOAD_ERROR` | `bool` | `false` | Raise errors when loading mounted components instead of logging warnings. | + +## Client + +| Environment Variable | Type | Default | Description | +|---|---|---|---| +| `FASTMCP_CLIENT_INIT_TIMEOUT` | `float \| None` | None | Timeout in seconds for the client initialization handshake. Set to `0` or leave unset to disable. | +| `FASTMCP_CLIENT_DISCONNECT_TIMEOUT` | `float` | `5` | Maximum time in seconds to wait for a clean disconnect before giving up. | +| `FASTMCP_CLIENT_RAISE_FIRST_EXCEPTIONGROUP_ERROR` | `bool` | `true` | When an `ExceptionGroup` is raised, re-raise the first error directly instead of the group. Simplifies debugging but may mask secondary errors. | + +## CLI & Display + +| Environment Variable | Type | Default | Description | +|---|---|---|---| +| `FASTMCP_SHOW_SERVER_BANNER` | `bool` | `true` | Show the server banner on startup. Also controllable via `--no-banner` or `server.run(show_banner=False)`. | +| `FASTMCP_CHECK_FOR_UPDATES` | `Literal["stable", "prerelease", "off"]` | `stable` | Update checking on CLI startup. `stable` checks stable releases only, `prerelease` includes pre-releases, `off` disables checking. | + +## Tasks (Docket) + +These configure the [Docket](https://github.com/prefecthq/docket) task queue used by [server tasks](/servers/tasks). All use the `FASTMCP_DOCKET_` prefix. + + +When setting Docket values in a `.env` file, use a **double** underscore: `FASTMCP_DOCKET__URL` (not `FASTMCP_DOCKET_URL`). This is because `.env` values are resolved through the parent `Settings` class, which uses `__` as its nested delimiter. As regular environment variables (e.g., `export`), the single-underscore form `FASTMCP_DOCKET_URL` works fine. + + +| Environment Variable | Type | Default | Description | +|---|---|---|---| +| `FASTMCP_DOCKET_NAME` | `str` | `fastmcp` | Queue name. Servers and workers sharing the same name and backend URL share a task queue. | +| `FASTMCP_DOCKET_URL` | `str` | `memory://` | Backend URL. Use `memory://` for single-process or `redis://host:port/db` for distributed workers. | +| `FASTMCP_DOCKET_WORKER_NAME` | `str \| None` | None | Worker name. Auto-generated if unset. | +| `FASTMCP_DOCKET_CONCURRENCY` | `int` | `10` | Maximum concurrent tasks per worker. | +| `FASTMCP_DOCKET_REDELIVERY_TIMEOUT` | `timedelta` | `300s` | If a worker doesn't complete a task within this time, it's redelivered to another worker. | +| `FASTMCP_DOCKET_RECONNECTION_DELAY` | `timedelta` | `5s` | Delay between reconnection attempts when the worker loses its backend connection. | +| `FASTMCP_DOCKET_MINIMUM_CHECK_INTERVAL` | `timedelta` | `50ms` | How frequently the worker polls for new tasks. Lower values reduce latency at the cost of more CPU usage. | + +## Advanced + +| Environment Variable | Type | Default | Description | +|---|---|---|---| +| `FASTMCP_HOME` | `Path` | Platform default | Data directory for FastMCP. Defaults to the platform-specific user data directory. | +| `FASTMCP_ENV_FILE` | `str` | `.env` | Path to the `.env` file to load settings from. Must be set as an environment variable (see above). | +| `FASTMCP_SERVER_DEPENDENCIES` | `list[str]` | `[]` | Additional dependencies to install in the server environment. | +| `FASTMCP_DECORATOR_MODE` | `Literal["function", "object"]` | `function` | Controls what `@tool`, `@resource`, and `@prompt` decorators return. `function` returns the original function (default); `object` returns component objects (deprecated, will be removed). | +| `FASTMCP_TEST_MODE` | `bool` | `false` | Enable test mode. | diff --git a/docs/python-sdk/fastmcp-apps-__init__.mdx b/docs/python-sdk/fastmcp-apps-__init__.mdx new file mode 100644 index 000000000..5f69a4b59 --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-__init__.mdx @@ -0,0 +1,16 @@ +--- +title: __init__ +sidebarTitle: __init__ +--- + +# `fastmcp.apps` + + +FastMCP Apps — interactive UIs for MCP tools. + +This package contains the app-related components: + +- ``FastMCPApp`` — composable provider for interactive apps with backend tools +- ``AppConfig`` — configuration for MCP App tools and resources +- ``ResourceCSP`` / ``ResourcePermissions`` — security configuration + diff --git a/docs/python-sdk/fastmcp-apps-app.mdx b/docs/python-sdk/fastmcp-apps-app.mdx new file mode 100644 index 000000000..04578add6 --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-app.mdx @@ -0,0 +1,146 @@ +--- +title: app +sidebarTitle: app +--- + +# `fastmcp.apps.app` + + +FastMCPApp — a Provider that represents a composable MCP application. + +FastMCPApp binds entry-point tools (model calls these) together with backend +tools (the UI calls these via CallTool). Backend tools are tagged with +``meta["fastmcp"]["app"]`` so they can be found through the provider chain +even when transforms (namespace, visibility, etc.) have renamed or hidden +them — the server sets a context var that tells ``Provider.get_tool`` to +fall back to a direct lookup for app-visible tools. + +Usage:: + + from fastmcp import FastMCP, FastMCPApp + + app = FastMCPApp("Dashboard") + + @app.ui() + def show_dashboard() -> Component: + return Column(...) + + @app.tool() + def save_contact(name: str, email: str) -> str: + return name + + server = FastMCP("Platform") + server.add_provider(app) + + +## Classes + +### `FastMCPApp` + + +A Provider that represents an MCP application. + +Binds together entry-point tools (``@app.ui``), backend tools +(``@app.tool``), and the Prefab renderer resource. Backend tools +are tagged with ``meta["fastmcp"]["app"]`` so ``Provider.get_tool`` +can find them by original name even when transforms have been applied. + + +**Methods:** + +#### `tool` + +```python +tool(self, name_or_fn: F) -> F +``` + +#### `tool` + +```python +tool(self, name_or_fn: str | None = None) -> Callable[[F], F] +``` + +#### `tool` + +```python +tool(self, name_or_fn: str | AnyFunction | None = None) -> Any +``` + +Register a backend tool that the UI calls via CallTool. + +Backend tools default to ``visibility=["app"]``. Pass ``model=True`` +to also expose the tool to the model (``visibility=["app", "model"]``). + +Supports multiple calling patterns:: + + @app.tool + def save(name: str): ... + + @app.tool() + def save(name: str): ... + + @app.tool("custom_name") + def save(name: str): ... + + +#### `ui` + +```python +ui(self, name_or_fn: F) -> F +``` + +#### `ui` + +```python +ui(self, name_or_fn: str | None = None) -> Callable[[F], F] +``` + +#### `ui` + +```python +ui(self, name_or_fn: str | AnyFunction | None = None) -> Any +``` + +Register a UI entry-point tool that the model calls. + +Entry-point tools default to ``visibility=["model"]`` and auto-wire +the Prefab renderer resource and CSP. They are tagged with the app +name so structured content includes ``_meta.fastmcp.app``. + +Supports multiple calling patterns:: + + @app.ui + def dashboard() -> Component: ... + + @app.ui() + def dashboard() -> Component: ... + + @app.ui("my_dashboard") + def dashboard() -> Component: ... + + +#### `add_tool` + +```python +add_tool(self, tool: Tool | Callable[..., Any]) -> Tool +``` + +Add a tool to this app programmatically. + +The tool is tagged with this app's name for routing. + + +#### `lifespan` + +```python +lifespan(self) -> AsyncIterator[None] +``` + +#### `run` + +```python +run(self, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, **kwargs: Any) -> None +``` + +Create a temporary FastMCP server and run this app standalone. + diff --git a/docs/python-sdk/fastmcp-apps-approval.mdx b/docs/python-sdk/fastmcp-apps-approval.mdx new file mode 100644 index 000000000..461a55c52 --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-approval.mdx @@ -0,0 +1,58 @@ +--- +title: approval +sidebarTitle: approval +--- + +# `fastmcp.apps.approval` + + +Approval — a Provider that adds human-in-the-loop approval to any server. + +The LLM presents a summary of what it's about to do, and the user +approves or rejects via buttons. The result is sent back into the +conversation as a message, prompting the LLM's next turn. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps.approval import Approval + + mcp = FastMCP("My Server") + mcp.add_provider(Approval()) + + +## Classes + +### `Approval` + + +A Provider that adds human-in-the-loop approval to a server. + +The LLM calls the ``request_approval`` tool with a summary and +optional details. The user sees an approval card with Approve and +Reject buttons. Clicking either sends a message back into the +conversation (via ``SendMessage``), triggering the LLM's next turn. + +The message appears as if the user sent it, so the LLM sees +something like ``'"Deploy v3.2 to production" is APPROVED'``. + +Example:: + + from fastmcp import FastMCP + from fastmcp.apps.approval import Approval + + mcp = FastMCP("My Server") + mcp.add_provider(Approval()) + +Customized:: + + Approval( + title="Deploy Gate", + approve_text="Ship it", + approve_variant="default", + reject_text="Abort", + reject_variant="destructive", + ) + diff --git a/docs/python-sdk/fastmcp-apps-choice.mdx b/docs/python-sdk/fastmcp-apps-choice.mdx new file mode 100644 index 000000000..4f693f898 --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-choice.mdx @@ -0,0 +1,44 @@ +--- +title: choice +sidebarTitle: choice +--- + +# `fastmcp.apps.choice` + + +Choice — a Provider that lets the user pick from a set of options. + +The LLM presents options, the user clicks one, and the selection +flows back into the conversation as a message. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps.choice import Choice + + mcp = FastMCP("My Server") + mcp.add_provider(Choice()) + + +## Classes + +### `Choice` + + +A Provider that lets the user choose from a set of options. + +The LLM calls ``choose`` with a prompt and a list of options. +The user sees a card with one button per option. Clicking a button +sends the selection back into the conversation via ``SendMessage``, +triggering the LLM's next turn. + +Example:: + + from fastmcp import FastMCP + from fastmcp.apps.choice import Choice + + mcp = FastMCP("My Server") + mcp.add_provider(Choice()) + diff --git a/docs/python-sdk/fastmcp-apps-config.mdx b/docs/python-sdk/fastmcp-apps-config.mdx new file mode 100644 index 000000000..d7c5edf2f --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-config.mdx @@ -0,0 +1,90 @@ +--- +title: config +sidebarTitle: config +--- + +# `fastmcp.apps.config` + + +MCP Apps support — extension negotiation and typed UI metadata models. + +Provides constants and Pydantic models for the MCP Apps extension +(io.modelcontextprotocol/ui), enabling tools and resources to carry +UI metadata for clients that support interactive app rendering. + + +## Functions + +### `app_config_to_meta_dict` + +```python +app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any] +``` + + +Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``. + + +## Classes + +### `ResourceCSP` + + +Content Security Policy for MCP App resources. + +Declares which external origins the app is allowed to connect to or +load resources from. Hosts use these declarations to build the +``Content-Security-Policy`` header for the sandboxed iframe. + + +### `ResourcePermissions` + + +Iframe sandbox permissions for MCP App resources. + +Each field, when set (typically to ``{}``), requests that the host +grant the corresponding Permission Policy feature to the sandboxed +iframe. Hosts MAY honour these; apps should use JS feature detection +as a fallback. + + +### `AppConfig` + + +Configuration for MCP App tools and resources. + +Controls how a tool or resource participates in the MCP Apps extension. +On tools, ``resource_uri`` and ``visibility`` specify which UI resource +to render and where the tool appears. On resources, those fields must +be left unset (the resource itself is the UI). + +All fields use ``exclude_none`` serialization so only explicitly-set +values appear on the wire. Aliases match the MCP Apps wire format +(camelCase). + + +### `PrefabAppConfig` + + +App configuration for Prefab tools with sensible defaults. + +Like ``app=True`` but customizable. Auto-wires the Prefab renderer +URI and merges the renderer's CSP with any additional domains you +specify. The renderer resource is registered automatically. + +Example:: + + @mcp.tool(app=PrefabAppConfig()) # same as app=True + + @mcp.tool(app=PrefabAppConfig( + csp=ResourceCSP(frame_domains=["https://example.com"]), + )) + + +**Methods:** + +#### `model_post_init` + +```python +model_post_init(self, __context: Any) -> None +``` diff --git a/docs/python-sdk/fastmcp-apps-file_upload.mdx b/docs/python-sdk/fastmcp-apps-file_upload.mdx new file mode 100644 index 000000000..9705e335b --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-file_upload.mdx @@ -0,0 +1,144 @@ +--- +title: file_upload +sidebarTitle: file_upload +--- + +# `fastmcp.apps.file_upload` + + +FileUpload — a Provider that adds drag-and-drop file upload to any server. + +Lets users upload files directly to the server through an interactive UI, +bypassing the LLM context window entirely. The LLM can then read and work +with uploaded files through model-visible tools. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps import FileUpload + + mcp = FastMCP("My Server") + mcp.add_provider(FileUpload()) + +For custom persistence, override the storage methods:: + + class S3Upload(FileUpload): + def on_store(self, files, ctx): + # write to S3, return summaries + ... + + def on_list(self, ctx): + # list from S3 + ... + + def on_read(self, name, ctx): + # read from S3 + ... + + +## Classes + +### `FileUpload` + + +A Provider that adds file upload capabilities to a server. + +Registers a drag-and-drop UI tool, a backend storage tool, and +model-visible tools for listing and reading uploaded files. + +Files are scoped by MCP session and stored in memory by default. +Override ``on_store``, ``on_list``, and ``on_read`` for custom +persistence (filesystem, S3, database, etc.). Each method receives +the current ``Context``, giving access to session ID, auth tokens, +and request metadata for partitioning and authorization. + +**Session scoping:** The default storage uses ``ctx.session_id`` to +isolate files by session. This works with stdio, SSE, and stateful +HTTP transports. In **stateless HTTP** mode, each request creates a +new session, so files won't persist across requests. For stateless +deployments, override the storage methods to partition by a stable +identifier from the auth context:: + + class UserScopedUpload(FileUpload): + def on_store(self, files, ctx): + user_id = ctx.access_token["sub"] + ... + +Example:: + + from fastmcp import FastMCP + from fastmcp.apps.file_upload import FileUpload + + mcp = FastMCP("My Server") + mcp.add_provider(FileUpload()) + + +**Methods:** + +#### `on_store` + +```python +on_store(self, files: list[dict[str, Any]], ctx: Context) -> list[dict[str, Any]] +``` + +Store uploaded files and return summaries. + +**Args:** +- `files`: List of file dicts, each with ``name``, ``size``, +``type``, and ``data`` (base64-encoded content). +- `ctx`: The current request context. Use for session ID, +auth tokens, or any metadata needed for partitioning. + +Override this method for custom persistence. The default +implementation stores files in memory, scoped by +``_get_scope_key(ctx)``. + +**Returns:** +- List of file summary dicts (``name``, ``type``, ``size``, +- ``size_display``, ``uploaded_at``). + + +#### `on_list` + +```python +on_list(self, ctx: Context) -> list[dict[str, Any]] +``` + +List all stored files. + +**Args:** +- `ctx`: The current request context. + +Override this method for custom persistence. The default +implementation returns files from the current scope. + +**Returns:** +- List of file summary dicts. + + +#### `on_read` + +```python +on_read(self, name: str, ctx: Context) -> dict[str, Any] +``` + +Read a file's contents by name. + +**Args:** +- `name`: The filename to read. +- `ctx`: The current request context. + +Override this method for custom persistence. The default +implementation reads from the current scope's in-memory store. +Text files are decoded from base64; binary files return a +truncated base64 preview. + +**Returns:** +- Dict with file metadata and ``content`` (text) or +- ``content_base64`` (binary preview). + +**Raises:** +- `ValueError`: If the file is not found. + diff --git a/docs/python-sdk/fastmcp-apps-form.mdx b/docs/python-sdk/fastmcp-apps-form.mdx new file mode 100644 index 000000000..edf6a72c9 --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-form.mdx @@ -0,0 +1,69 @@ +--- +title: form +sidebarTitle: form +--- + +# `fastmcp.apps.form` + + +FormInput — a Provider that collects structured input from the user. + +Define a Pydantic model for the data you need, and ``FormInput`` +generates a form UI. The user fills it out, the submission is +validated, and an optional callback processes the result. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from pydantic import BaseModel + from fastmcp import FastMCP + from fastmcp.apps.form import FormInput + + class ShippingAddress(BaseModel): + street: str + city: str + state: str + zip_code: str + + mcp = FastMCP("My Server") + mcp.add_provider(FormInput(model=ShippingAddress)) + + +## Classes + +### `FormInput` + + +A Provider that collects structured input via a Pydantic model. + +Define a model for the data you need, and ``FormInput`` generates +a form from it using ``Form.from_model()``. Field types, labels, +descriptions, and validation are all derived from the model. + +Optionally provide an ``on_submit`` callback to process the +validated data. The callback receives a model instance and returns +a string that goes back to the LLM. Without a callback, the +validated JSON is sent directly. + +Example:: + + from pydantic import BaseModel + from fastmcp import FastMCP + from fastmcp.apps.form import FormInput + + class Contact(BaseModel): + name: str + email: str + + mcp = FastMCP("My Server") + mcp.add_provider(FormInput(model=Contact)) + +With a callback:: + + def save_contact(contact: Contact) -> str: + db.insert(contact.model_dump()) + return f"Saved {contact.name}" + + mcp.add_provider(FormInput(model=Contact, on_submit=save_contact)) + diff --git a/docs/python-sdk/fastmcp-apps-generative.mdx b/docs/python-sdk/fastmcp-apps-generative.mdx new file mode 100644 index 000000000..336d4f353 --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-generative.mdx @@ -0,0 +1,56 @@ +--- +title: generative +sidebarTitle: generative +--- + +# `fastmcp.apps.generative` + + +GenerativeUI — a Provider that adds LLM-generated UI capabilities. + +Registers tools and resources from ``prefab_ui.generative`` so that an +LLM can write Prefab Python code, execute it in a sandbox, and render +the result as a streaming interactive UI. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps.generative import GenerativeUI + + mcp = FastMCP("My Server") + mcp.add_provider(GenerativeUI()) + + +## Classes + +### `GenerativeUI` + + +A Provider that adds generative UI capabilities to a server. + +Registers: + +- A ``generate_ui`` tool that accepts Prefab Python code, executes + it in a Pyodide sandbox, and returns the rendered PrefabApp. + Supports streaming via ``ontoolinputpartial``. +- A ``components`` tool that searches the Prefab component library. +- The generative renderer resource with CSP for Pyodide CDN access. + +Example:: + + from fastmcp import FastMCP + from fastmcp.apps.generative import GenerativeUI + + mcp = FastMCP("My Server") + mcp.add_provider(GenerativeUI()) + + +**Methods:** + +#### `lifespan` + +```python +lifespan(self) -> AsyncIterator[None] +``` diff --git a/docs/python-sdk/fastmcp-cli-apps_dev.mdx b/docs/python-sdk/fastmcp-cli-apps_dev.mdx new file mode 100644 index 000000000..2f38bbc5b --- /dev/null +++ b/docs/python-sdk/fastmcp-cli-apps_dev.mdx @@ -0,0 +1,47 @@ +--- +title: apps_dev +sidebarTitle: apps_dev +--- + +# `fastmcp.cli.apps_dev` + + +Dev server for previewing FastMCPApp UIs locally. + +Starts the user's MCP server on a configurable port, then starts a lightweight +Starlette dev server that: + + - Serves a Prefab-based tool picker at GET / + - Proxies /mcp to the user's server (avoids browser CORS restrictions) + - Serves the AppBridge host page at GET /launch + +The host page uses @modelcontextprotocol/ext-apps to connect to the MCP server +and render the selected UI tool inside an iframe. + +Startup sequence +---------------- +1. Download ext-apps app-bridge.js from npm and patch its bare + ``@modelcontextprotocol/sdk/…`` imports to use concrete esm.sh URLs. +2. Detect the exact Zod v4 module URL that esm.sh serves for that SDK version + and build an import-map entry that redirects the broken ``v4.mjs`` (which + only re-exports ``{z, default}``) to ``v4/classic/index.mjs`` (which + correctly exports every named Zod v4 function). Import maps apply to the + full module graph in the document, including cross-origin esm.sh modules. +3. Serve both the patched JS and the import-map JSON from the dev server. + + +## Functions + +### `run_dev_apps` + +```python +run_dev_apps(server_spec: str) -> None +``` + + +Start the full dev environment for a FastMCPApp server. + +Starts the user's MCP server on *mcp_port*, starts the Prefab dev UI +on *dev_port* (with an /mcp proxy to the user's server), then opens +the browser. + diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index 60804a298..b8bf7e0de 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -50,7 +50,23 @@ Run an MCP server with the MCP Inspector for development. - `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json -### `run` +### `apps` + +```python +apps(server_spec: str) -> None +``` + + +Preview a FastMCPApp UI in the browser. + +Starts the MCP server from SERVER_SPEC on --mcp-port, launches a local +dev UI on --dev-port with a tool picker and AppBridge host, then opens +the browser automatically. + +Requires fastmcp[apps] to be installed (prefab-ui). + + +### `run` ```python run(server_spec: str | None = None, *server_args: str) -> None @@ -75,7 +91,7 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None @@ -106,7 +122,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-cli-client.mdx b/docs/python-sdk/fastmcp-cli-client.mdx index 726663bfb..78dad8b29 100644 --- a/docs/python-sdk/fastmcp-cli-client.mdx +++ b/docs/python-sdk/fastmcp-cli-client.mdx @@ -10,7 +10,7 @@ Client-side CLI commands for querying and invoking MCP servers. ## Functions -### `resolve_server_spec` +### `resolve_server_spec` ```python resolve_server_spec(server_spec: str | None) -> str | dict[str, Any] | ClientTransport @@ -32,7 +32,7 @@ When ``command`` is provided, the string is shell-split into a ``StdioTransport(command, args)``. -### `coerce_value` +### `coerce_value` ```python coerce_value(raw: str, schema: dict[str, Any]) -> Any @@ -42,7 +42,7 @@ coerce_value(raw: str, schema: dict[str, Any]) -> Any Coerce a string CLI value according to a JSON-Schema type hint. -### `parse_tool_arguments` +### `parse_tool_arguments` ```python parse_tool_arguments(raw_args: tuple[str, ...], input_json: str | None, input_schema: dict[str, Any]) -> dict[str, Any] @@ -56,7 +56,7 @@ A single JSON object argument is treated as the full argument dict. Values are coerced using the tool's ``inputSchema``. -### `format_tool_signature` +### `format_tool_signature` ```python format_tool_signature(tool: mcp.types.Tool) -> str @@ -66,7 +66,7 @@ format_tool_signature(tool: mcp.types.Tool) -> str Build ``name(param: type, ...) -> return_type`` from a tool's JSON schemas. -### `list_command` +### `list_command` ```python list_command(server_spec: Annotated[str | None, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, or .js file')] = None) -> None @@ -84,7 +84,7 @@ fastmcp list --command 'npx -y @mcp/server' --resources fastmcp list http://server/mcp --transport sse -### `call_command` +### `call_command` ```python call_command(server_spec: Annotated[str | None, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, or .js file')] = None, target: Annotated[str, cyclopts.Parameter(help='Tool name, resource URI, or prompt name (with --prompt)')] = '', *arguments: str) -> None @@ -110,7 +110,7 @@ fastmcp call http://server/mcp create --input-json '{"tags": ["a","b"]}' ``` -### `discover_command` +### `discover_command` ```python discover_command() -> None diff --git a/docs/python-sdk/fastmcp-cli-install-claude_code.mdx b/docs/python-sdk/fastmcp-cli-install-claude_code.mdx index 0a3393077..675faafc4 100644 --- a/docs/python-sdk/fastmcp-cli-install-claude_code.mdx +++ b/docs/python-sdk/fastmcp-cli-install-claude_code.mdx @@ -57,7 +57,7 @@ Install FastMCP server in Claude Code. - True if installation was successful, False otherwise -### `claude_code_command` +### `claude_code_command` ```python claude_code_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx b/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx index 23f7a1b27..2c06c6020 100644 --- a/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx +++ b/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx @@ -13,14 +13,17 @@ Claude Desktop integration for FastMCP install using Cyclopts. ### `get_claude_config_path` ```python -get_claude_config_path() -> Path | None +get_claude_config_path(config_path: Path | None = None) -> Path | None ``` Get the Claude config directory based on platform. +**Args:** +- `config_path`: Optional custom path to the Claude Desktop config directory -### `install_claude_desktop` + +### `install_claude_desktop` ```python install_claude_desktop(file: Path, server_object: str | None, name: str) -> bool @@ -39,12 +42,13 @@ Install FastMCP server in Claude Desktop. - `python_version`: Optional Python version to use - `with_requirements`: Optional requirements file to install from - `project`: Optional project directory to run within +- `config_path`: Optional custom path to Claude Desktop config directory **Returns:** - True if installation was successful, False otherwise -### `claude_desktop_command` +### `claude_desktop_command` ```python claude_desktop_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-cursor.mdx b/docs/python-sdk/fastmcp-cli-install-cursor.mdx index a61bca0ff..e6a964eed 100644 --- a/docs/python-sdk/fastmcp-cli-install-cursor.mdx +++ b/docs/python-sdk/fastmcp-cli-install-cursor.mdx @@ -68,7 +68,7 @@ Install FastMCP server to workspace-specific Cursor configuration. - True if installation was successful, False otherwise -### `install_cursor` +### `install_cursor` ```python install_cursor(file: Path, server_object: str | None, name: str) -> bool @@ -93,7 +93,7 @@ Install FastMCP server in Cursor. - True if installation was successful, False otherwise -### `cursor_command` +### `cursor_command` ```python cursor_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-gemini_cli.mdx b/docs/python-sdk/fastmcp-cli-install-gemini_cli.mdx index 9cb51f0f4..d80716460 100644 --- a/docs/python-sdk/fastmcp-cli-install-gemini_cli.mdx +++ b/docs/python-sdk/fastmcp-cli-install-gemini_cli.mdx @@ -54,7 +54,7 @@ Install FastMCP server in Gemini CLI. - True if installation was successful, False otherwise -### `gemini_cli_command` +### `gemini_cli_command` ```python gemini_cli_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-shared.mdx b/docs/python-sdk/fastmcp-cli-install-shared.mdx index a1fe2119c..b51b0a424 100644 --- a/docs/python-sdk/fastmcp-cli-install-shared.mdx +++ b/docs/python-sdk/fastmcp-cli-install-shared.mdx @@ -10,7 +10,19 @@ Shared utilities for install commands. ## Functions -### `parse_env_var` +### `validate_server_name` + +```python +validate_server_name(name: str) -> str +``` + + +Validate that a server name is safe for use as a subprocess argument. + +Raises SystemExit if the name contains shell metacharacters. + + +### `parse_env_var` ```python parse_env_var(env_var: str) -> tuple[str, str] @@ -20,7 +32,7 @@ parse_env_var(env_var: str) -> tuple[str, str] Parse environment variable string in format KEY=VALUE. -### `process_common_args` +### `process_common_args` ```python process_common_args(server_spec: str, server_name: str | None, with_packages: list[str] | None, env_vars: list[str] | None, env_file: Path | None) -> tuple[Path, str | None, str, list[str], dict[str, str] | None] @@ -32,7 +44,7 @@ Process common arguments shared by all install commands. Handles both fastmcp.json config files and traditional file.py:object syntax. -### `open_deeplink` +### `open_deeplink` ```python open_deeplink(url: str) -> bool diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx index 455ea1337..85d4e536a 100644 --- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx +++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx @@ -32,37 +32,43 @@ Raised when OAuth client credentials are not found on the server. **Methods:** -#### `clear` +#### `clear` ```python clear(self) -> None ``` -#### `get_tokens` +#### `get_tokens` ```python get_tokens(self) -> OAuthToken | None ``` -#### `set_tokens` +#### `set_tokens` ```python set_tokens(self, tokens: OAuthToken) -> None ``` -#### `get_client_info` +#### `get_token_expiry` + +```python +get_token_expiry(self) -> float | None +``` + +#### `get_client_info` ```python get_client_info(self) -> OAuthClientInformationFull | None ``` -#### `set_client_info` +#### `set_client_info` ```python set_client_info(self, client_info: OAuthClientInformationFull) -> None ``` -### `OAuth` +### `OAuth` OAuth client provider for MCP servers with browser-based authentication. @@ -73,7 +79,7 @@ a browser for user authorization and running a local callback server. **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -82,7 +88,7 @@ redirect_handler(self, authorization_url: str) -> None Open browser for authorization, with pre-flight check for invalid client. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] @@ -91,7 +97,7 @@ callback_handler(self) -> tuple[str, str | None] Handle OAuth callback and return (auth_code, state). -#### `async_auth_flow` +#### `async_auth_flow` ```python async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response] diff --git a/docs/python-sdk/fastmcp-client-client.mdx b/docs/python-sdk/fastmcp-client-client.mdx index 6c3ac689a..efbf56f08 100644 --- a/docs/python-sdk/fastmcp-client-client.mdx +++ b/docs/python-sdk/fastmcp-client-client.mdx @@ -7,7 +7,7 @@ sidebarTitle: client ## Classes -### `ClientSessionState` +### `ClientSessionState` Holds all session-related state for a Client instance. @@ -16,13 +16,13 @@ This allows clean separation of configuration (which is copied) from session state (which should be fresh for each new client instance). -### `CallToolResult` +### `CallToolResult` Parsed result from a tool call. -### `Client` +### `Client` MCP client that delegates connection management to a Transport instance. @@ -85,7 +85,7 @@ async with client: **Methods:** -#### `session` +#### `session` ```python session(self) -> ClientSession @@ -94,7 +94,7 @@ session(self) -> ClientSession Get the current active session. Raises RuntimeError if not connected. -#### `initialize_result` +#### `initialize_result` ```python initialize_result(self) -> mcp.types.InitializeResult | None @@ -103,7 +103,7 @@ initialize_result(self) -> mcp.types.InitializeResult | None Get the result of the initialization request. -#### `set_roots` +#### `set_roots` ```python set_roots(self, roots: RootsList | RootsHandler) -> None @@ -112,7 +112,7 @@ set_roots(self, roots: RootsList | RootsHandler) -> None Set the roots for the client. This does not automatically call `send_roots_list_changed`. -#### `set_sampling_callback` +#### `set_sampling_callback` ```python set_sampling_callback(self, sampling_callback: SamplingHandler, sampling_capabilities: mcp.types.SamplingCapability | None = None) -> None @@ -121,7 +121,7 @@ set_sampling_callback(self, sampling_callback: SamplingHandler, sampling_capabil Set the sampling callback for the client. -#### `set_elicitation_callback` +#### `set_elicitation_callback` ```python set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None @@ -130,7 +130,7 @@ set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None Set the elicitation callback for the client. -#### `is_connected` +#### `is_connected` ```python is_connected(self) -> bool @@ -139,7 +139,7 @@ is_connected(self) -> bool Check if the client is currently connected. -#### `new` +#### `new` ```python new(self) -> Client[ClientTransportT] @@ -155,7 +155,7 @@ share state with the original client. - A new Client instance with the same configuration but disconnected state. -#### `initialize` +#### `initialize` ```python initialize(self, timeout: datetime.timedelta | float | int | None = None) -> mcp.types.InitializeResult @@ -183,13 +183,13 @@ capabilities, protocol version, and optional instructions. - `RuntimeError`: If the client is not connected or initialization times out. -#### `close` +#### `close` ```python close(self) ``` -#### `ping` +#### `ping` ```python ping(self) -> bool @@ -198,7 +198,7 @@ ping(self) -> bool Send a ping request. -#### `cancel` +#### `cancel` ```python cancel(self, request_id: str | int, reason: str | None = None) -> None @@ -207,7 +207,7 @@ cancel(self, request_id: str | int, reason: str | None = None) -> None Send a cancellation notification for an in-progress request. -#### `progress` +#### `progress` ```python progress(self, progress_token: str | int, progress: float, total: float | None = None, message: str | None = None) -> None @@ -216,7 +216,7 @@ progress(self, progress_token: str | int, progress: float, total: float | None = Send a progress notification. -#### `set_logging_level` +#### `set_logging_level` ```python set_logging_level(self, level: mcp.types.LoggingLevel) -> None @@ -225,7 +225,7 @@ set_logging_level(self, level: mcp.types.LoggingLevel) -> None Send a logging/setLevel request. -#### `send_roots_list_changed` +#### `send_roots_list_changed` ```python send_roots_list_changed(self) -> None @@ -234,7 +234,7 @@ send_roots_list_changed(self) -> None Send a roots/list_changed notification. -#### `complete_mcp` +#### `complete_mcp` ```python complete_mcp(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None) -> mcp.types.CompleteResult @@ -257,7 +257,7 @@ containing the completion and any additional metadata. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `complete` +#### `complete` ```python complete(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None) -> mcp.types.Completion @@ -279,7 +279,7 @@ include with the completion request. Defaults to None. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-client-mixins-prompts.mdx b/docs/python-sdk/fastmcp-client-mixins-prompts.mdx index 3931c03db..f91e79a9b 100644 --- a/docs/python-sdk/fastmcp-client-mixins-prompts.mdx +++ b/docs/python-sdk/fastmcp-client-mixins-prompts.mdx @@ -10,7 +10,7 @@ Prompt-related methods for FastMCP Client. ## Classes -### `ClientPromptsMixin` +### `ClientPromptsMixin` Mixin providing prompt-related methods for Client. @@ -18,7 +18,7 @@ Mixin providing prompt-related methods for Client. **Methods:** -#### `list_prompts_mcp` +#### `list_prompts_mcp` ```python list_prompts_mcp(self: Client) -> mcp.types.ListPromptsResult @@ -38,10 +38,10 @@ containing the list of prompts and any additional metadata. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `list_prompts` +#### `list_prompts` ```python -list_prompts(self: Client) -> list[mcp.types.Prompt] +list_prompts(self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES) -> list[mcp.types.Prompt] ``` Retrieve all prompts available on the server. @@ -50,15 +50,18 @@ This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_prompts_mcp() with the cursor parameter. +**Args:** +- `max_pages`: Maximum number of pages to fetch before raising. Defaults to 250. + **Returns:** - list\[mcp.types.Prompt]: A list of all Prompt objects. **Raises:** -- `RuntimeError`: If called while the client is not connected. +- `RuntimeError`: If the page limit is reached before pagination completes. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `get_prompt_mcp` +#### `get_prompt_mcp` ```python get_prompt_mcp(self: Client, name: str, arguments: dict[str, Any] | None = None, meta: dict[str, Any] | None = None) -> mcp.types.GetPromptResult @@ -80,19 +83,19 @@ containing the prompt messages and any additional metadata. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self: Client, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult ``` -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self: Client, name: str, arguments: dict[str, Any] | None = None) -> PromptTask ``` -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self: Client, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult | PromptTask diff --git a/docs/python-sdk/fastmcp-client-mixins-resources.mdx b/docs/python-sdk/fastmcp-client-mixins-resources.mdx index 655101ac3..70f07c7e7 100644 --- a/docs/python-sdk/fastmcp-client-mixins-resources.mdx +++ b/docs/python-sdk/fastmcp-client-mixins-resources.mdx @@ -10,7 +10,7 @@ Resource-related methods for FastMCP Client. ## Classes -### `ClientResourcesMixin` +### `ClientResourcesMixin` Mixin providing resource-related methods for Client. @@ -18,7 +18,7 @@ Mixin providing resource-related methods for Client. **Methods:** -#### `list_resources_mcp` +#### `list_resources_mcp` ```python list_resources_mcp(self: Client) -> mcp.types.ListResourcesResult @@ -38,10 +38,10 @@ containing the list of resources and any additional metadata. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `list_resources` +#### `list_resources` ```python -list_resources(self: Client) -> list[mcp.types.Resource] +list_resources(self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES) -> list[mcp.types.Resource] ``` Retrieve all resources available on the server. @@ -50,15 +50,18 @@ This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_resources_mcp() with the cursor parameter. +**Args:** +- `max_pages`: Maximum number of pages to fetch before raising. Defaults to 250. + **Returns:** - list\[mcp.types.Resource]: A list of all Resource objects. **Raises:** -- `RuntimeError`: If called while the client is not connected. +- `RuntimeError`: If the page limit is reached before pagination completes. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `list_resource_templates_mcp` +#### `list_resource_templates_mcp` ```python list_resource_templates_mcp(self: Client) -> mcp.types.ListResourceTemplatesResult @@ -78,10 +81,10 @@ containing the list of resource templates and any additional metadata. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `list_resource_templates` +#### `list_resource_templates` ```python -list_resource_templates(self: Client) -> list[mcp.types.ResourceTemplate] +list_resource_templates(self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES) -> list[mcp.types.ResourceTemplate] ``` Retrieve all resource templates available on the server. @@ -91,15 +94,18 @@ returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_resource_templates_mcp() with the cursor parameter. +**Args:** +- `max_pages`: Maximum number of pages to fetch before raising. Defaults to 250. + **Returns:** - list\[mcp.types.ResourceTemplate]: A list of all ResourceTemplate objects. **Raises:** -- `RuntimeError`: If called while the client is not connected. +- `RuntimeError`: If the page limit is reached before pagination completes. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `read_resource_mcp` +#### `read_resource_mcp` ```python read_resource_mcp(self: Client, uri: AnyUrl | str, meta: dict[str, Any] | None = None) -> mcp.types.ReadResourceResult @@ -120,19 +126,19 @@ containing the resource contents and any additional metadata. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `read_resource` +#### `read_resource` ```python read_resource(self: Client, uri: AnyUrl | str) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self: Client, uri: AnyUrl | str) -> ResourceTask ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self: Client, uri: AnyUrl | str) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] | ResourceTask diff --git a/docs/python-sdk/fastmcp-client-mixins-tools.mdx b/docs/python-sdk/fastmcp-client-mixins-tools.mdx index f048ed070..8711bfeb8 100644 --- a/docs/python-sdk/fastmcp-client-mixins-tools.mdx +++ b/docs/python-sdk/fastmcp-client-mixins-tools.mdx @@ -10,7 +10,7 @@ Tool-related methods for FastMCP Client. ## Classes -### `ClientToolsMixin` +### `ClientToolsMixin` Mixin providing tool-related methods for Client. @@ -18,7 +18,7 @@ Mixin providing tool-related methods for Client. **Methods:** -#### `list_tools_mcp` +#### `list_tools_mcp` ```python list_tools_mcp(self: Client) -> mcp.types.ListToolsResult @@ -38,10 +38,10 @@ containing the list of tools and any additional metadata. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `list_tools` +#### `list_tools` ```python -list_tools(self: Client) -> list[mcp.types.Tool] +list_tools(self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES) -> list[mcp.types.Tool] ``` Retrieve all tools available on the server. @@ -50,15 +50,18 @@ This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_tools_mcp() with the cursor parameter. +**Args:** +- `max_pages`: Maximum number of pages to fetch before raising. Defaults to 250. + **Returns:** - list\[mcp.types.Tool]: A list of all Tool objects. **Raises:** -- `RuntimeError`: If called while the client is not connected. +- `RuntimeError`: If the page limit is reached before pagination completes. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `call_tool_mcp` +#### `call_tool_mcp` ```python call_tool_mcp(self: Client, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, meta: dict[str, Any] | None = None) -> mcp.types.CallToolResult @@ -88,19 +91,19 @@ containing the tool result and any additional metadata. - `McpError`: If the tool call requests results in a TimeoutError | JSONRPCError -#### `call_tool` +#### `call_tool` ```python call_tool(self: Client, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self: Client, name: str, arguments: dict[str, Any] | None = None) -> ToolTask ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self: Client, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult | ToolTask diff --git a/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx b/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx index 976367c28..ff48e7a31 100644 --- a/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx +++ b/docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx @@ -10,7 +10,7 @@ Anthropic sampling handler for FastMCP. ## Classes -### `AnthropicSamplingHandler` +### `AnthropicSamplingHandler` Sampling handler that uses the Anthropic API. diff --git a/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx b/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx index 9681c3a4a..d55619c72 100644 --- a/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx +++ b/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx @@ -10,7 +10,7 @@ Google GenAI sampling handler with tool support for FastMCP 3.0. ## Classes -### `GoogleGenaiSamplingHandler` +### `GoogleGenaiSamplingHandler` Sampling handler that uses the Google GenAI API with tool support. diff --git a/docs/python-sdk/fastmcp-client-sampling-handlers-openai.mdx b/docs/python-sdk/fastmcp-client-sampling-handlers-openai.mdx index b291bbaa5..2d7976e0f 100644 --- a/docs/python-sdk/fastmcp-client-sampling-handlers-openai.mdx +++ b/docs/python-sdk/fastmcp-client-sampling-handlers-openai.mdx @@ -10,7 +10,7 @@ OpenAI sampling handler for FastMCP. ## Classes -### `OpenAISamplingHandler` +### `OpenAISamplingHandler` Sampling handler that uses the OpenAI API. diff --git a/docs/python-sdk/fastmcp-client-transports-config.mdx b/docs/python-sdk/fastmcp-client-transports-config.mdx index 7ad10e0df..3881c69e1 100644 --- a/docs/python-sdk/fastmcp-client-transports-config.mdx +++ b/docs/python-sdk/fastmcp-client-transports-config.mdx @@ -7,7 +7,7 @@ sidebarTitle: config ## Classes -### `MCPConfigTransport` +### `MCPConfigTransport` Transport for connecting to one or more MCP servers defined in an MCPConfig. @@ -59,13 +59,13 @@ async with client: **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -#### `close` +#### `close` ```python close(self) diff --git a/docs/python-sdk/fastmcp-client-transports-http.mdx b/docs/python-sdk/fastmcp-client-transports-http.mdx index a3375240e..a0db1401d 100644 --- a/docs/python-sdk/fastmcp-client-transports-http.mdx +++ b/docs/python-sdk/fastmcp-client-transports-http.mdx @@ -10,7 +10,7 @@ Streamable HTTP transport for FastMCP Client. ## Classes -### `StreamableHttpTransport` +### `StreamableHttpTransport` Transport implementation that connects to an MCP server via Streamable HTTP Requests. @@ -18,19 +18,19 @@ Transport implementation that connects to an MCP server via Streamable HTTP Requ **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -#### `get_session_id` +#### `get_session_id` ```python get_session_id(self) -> str | None ``` -#### `close` +#### `close` ```python close(self) diff --git a/docs/python-sdk/fastmcp-client-transports-sse.mdx b/docs/python-sdk/fastmcp-client-transports-sse.mdx index 59c145401..a65dace46 100644 --- a/docs/python-sdk/fastmcp-client-transports-sse.mdx +++ b/docs/python-sdk/fastmcp-client-transports-sse.mdx @@ -10,7 +10,7 @@ Server-Sent Events (SSE) transport for FastMCP Client. ## Classes -### `SSETransport` +### `SSETransport` Transport implementation that connects to an MCP server via Server-Sent Events. @@ -18,7 +18,7 @@ Transport implementation that connects to an MCP server via Server-Sent Events. **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] diff --git a/docs/python-sdk/fastmcp-client-transports-stdio.mdx b/docs/python-sdk/fastmcp-client-transports-stdio.mdx index eb7d98eb2..ac317bfc3 100644 --- a/docs/python-sdk/fastmcp-client-transports-stdio.mdx +++ b/docs/python-sdk/fastmcp-client-transports-stdio.mdx @@ -30,49 +30,49 @@ connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ connect(self, **session_kwargs: Unpack[SessionKwargs]) -> ClientSession | None ``` -#### `disconnect` +#### `disconnect` ```python disconnect(self) ``` -#### `close` +#### `close` ```python close(self) ``` -### `PythonStdioTransport` +### `PythonStdioTransport` Transport for running Python scripts. -### `FastMCPStdioTransport` +### `FastMCPStdioTransport` Transport for running FastMCP servers using the FastMCP CLI. -### `NodeStdioTransport` +### `NodeStdioTransport` Transport for running Node.js scripts. -### `UvStdioTransport` +### `UvStdioTransport` Transport for running commands via the uv tool. -### `UvxStdioTransport` +### `UvxStdioTransport` Transport for running commands via the uvx tool. -### `NpxStdioTransport` +### `NpxStdioTransport` Transport for running commands via the npx tool. diff --git a/docs/python-sdk/fastmcp-exceptions.mdx b/docs/python-sdk/fastmcp-exceptions.mdx index d8f5a6871..3494751f7 100644 --- a/docs/python-sdk/fastmcp-exceptions.mdx +++ b/docs/python-sdk/fastmcp-exceptions.mdx @@ -10,61 +10,71 @@ Custom exceptions for FastMCP. ## Classes -### `FastMCPError` +### `FastMCPDeprecationWarning` + + +Deprecation warning for FastMCP APIs. + +Subclass of DeprecationWarning so that standard warning filters +still apply, but FastMCP can selectively enable its own warnings +without affecting other libraries in the process. + + +### `FastMCPError` Base error for FastMCP. -### `ValidationError` +### `ValidationError` Error in validating parameters or return values. -### `ResourceError` +### `ResourceError` Error in resource operations. -### `ToolError` +### `ToolError` Error in tool operations. -### `PromptError` +### `PromptError` Error in prompt operations. -### `InvalidSignature` +### `InvalidSignature` Invalid signature for use with FastMCP. -### `ClientError` +### `ClientError` Error in client operations. -### `NotFoundError` +### `NotFoundError` Object not found. -### `DisabledError` +### `DisabledError` Object is disabled. -### `AuthorizationError` +### `AuthorizationError` Error when authorization check fails. diff --git a/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx b/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx index 0553029eb..9dc66b06a 100644 --- a/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx +++ b/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx @@ -47,7 +47,7 @@ leave that limit uncapped. run(self, code: str) -> Any ``` -### `Search` +### `Search` Discovery tool factory that searches the catalog by query. @@ -64,7 +64,7 @@ Defaults to BM25 ranking. The LLM can override this per call. ``None`` means no limit. -### `GetSchemas` +### `GetSchemas` Discovery tool factory that returns schemas for tools by name. @@ -78,7 +78,7 @@ types, and required markers. ``"full"`` returns the complete JSON schema. -### `GetTags` +### `GetTags` Discovery tool factory that lists tool tags from the catalog. @@ -93,7 +93,7 @@ without tags appear under ``"untagged"``. ``"full"`` lists all tools under each tag. -### `ListTools` +### `ListTools` Discovery tool factory that lists all tools in the catalog. @@ -106,7 +106,7 @@ Discovery tool factory that lists all tools in the catalog. ``"full"`` returns the complete JSON schema. -### `CodeMode` +### `CodeMode` Transform that collapses all tools into discovery + execute meta-tools. @@ -123,13 +123,13 @@ environment with ``call_tool(name, params)`` in scope. **Methods:** -#### `transform_tools` +#### `transform_tools` ```python transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None diff --git a/docs/python-sdk/fastmcp-prompts-prompt.mdx b/docs/python-sdk/fastmcp-prompts-base.mdx similarity index 64% rename from docs/python-sdk/fastmcp-prompts-prompt.mdx rename to docs/python-sdk/fastmcp-prompts-base.mdx index 2fe759342..1c57fbea2 100644 --- a/docs/python-sdk/fastmcp-prompts-prompt.mdx +++ b/docs/python-sdk/fastmcp-prompts-base.mdx @@ -1,16 +1,16 @@ --- -title: prompt -sidebarTitle: prompt +title: base +sidebarTitle: base --- -# `fastmcp.prompts.prompt` +# `fastmcp.prompts.base` Base classes for FastMCP prompts. ## Classes -### `Message` +### `Message` Wrapper for prompt message with auto-serialization. @@ -21,7 +21,7 @@ Accepts any content - strings pass through, other types **Methods:** -#### `to_mcp_prompt_message` +#### `to_mcp_prompt_message` ```python to_mcp_prompt_message(self) -> PromptMessage @@ -30,13 +30,13 @@ to_mcp_prompt_message(self) -> PromptMessage Convert to MCP PromptMessage. -### `PromptArgument` +### `PromptArgument` An argument that can be passed to a prompt. -### `PromptResult` +### `PromptResult` Canonical result type for prompt rendering. @@ -47,7 +47,7 @@ roles, and metadata at both the message and result level. **Methods:** -#### `to_mcp_prompt_result` +#### `to_mcp_prompt_result` ```python to_mcp_prompt_result(self) -> GetPromptResult @@ -56,7 +56,7 @@ to_mcp_prompt_result(self) -> GetPromptResult Convert to MCP GetPromptResult. -### `Prompt` +### `Prompt` A prompt template that can be rendered with parameters. @@ -64,7 +64,7 @@ A prompt template that can be rendered with parameters. **Methods:** -#### `to_mcp_prompt` +#### `to_mcp_prompt` ```python to_mcp_prompt(self, **overrides: Any) -> SDKPrompt @@ -73,7 +73,7 @@ to_mcp_prompt(self, **overrides: Any) -> SDKPrompt Convert the prompt to an MCP prompt. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionPrompt @@ -87,7 +87,7 @@ The function can return: - PromptResult: used directly -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> str | list[Message | str] | PromptResult @@ -101,7 +101,7 @@ Subclasses must implement this method. Return one of: - PromptResult: Used directly -#### `convert_result` +#### `convert_result` ```python convert_result(self, raw_value: Any) -> PromptResult @@ -113,7 +113,7 @@ Convert a raw return value to PromptResult. - `TypeError`: for unsupported types -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -122,7 +122,7 @@ register_with_docket(self, docket: Docket) -> None Register this prompt with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any] | None, **kwargs: Any) -> Execution @@ -138,7 +138,7 @@ Schedule this prompt for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-prompts-function_prompt.mdx b/docs/python-sdk/fastmcp-prompts-function_prompt.mdx index a3222afc0..92f369d78 100644 --- a/docs/python-sdk/fastmcp-prompts-function_prompt.mdx +++ b/docs/python-sdk/fastmcp-prompts-function_prompt.mdx @@ -10,7 +10,7 @@ Standalone @prompt decorator for FastMCP. ## Functions -### `prompt` +### `prompt` ```python prompt(name_or_fn: str | Callable[..., Any] | None = None) -> Any @@ -25,19 +25,19 @@ using mcp.add_prompt(). ## Classes -### `DecoratedPrompt` +### `DecoratedPrompt` Protocol for functions decorated with @prompt. -### `PromptMeta` +### `PromptMeta` Metadata attached to functions by the @prompt decorator. -### `FunctionPrompt` +### `FunctionPrompt` A prompt that is a function. @@ -45,7 +45,7 @@ A prompt that is a function. **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionPrompt @@ -66,7 +66,7 @@ The function can return: - PromptResult: used directly -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> PromptResult @@ -75,7 +75,7 @@ render(self, arguments: dict[str, Any] | None = None) -> PromptResult Render the prompt with arguments. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -87,7 +87,7 @@ FunctionPrompt registers the underlying function, which has the user's Depends parameters for docket to resolve. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any] | None, **kwargs: Any) -> Execution diff --git a/docs/python-sdk/fastmcp-resources-resource.mdx b/docs/python-sdk/fastmcp-resources-base.mdx similarity index 70% rename from docs/python-sdk/fastmcp-resources-resource.mdx rename to docs/python-sdk/fastmcp-resources-base.mdx index 029b48700..aab4a1dd7 100644 --- a/docs/python-sdk/fastmcp-resources-resource.mdx +++ b/docs/python-sdk/fastmcp-resources-base.mdx @@ -1,16 +1,16 @@ --- -title: resource -sidebarTitle: resource +title: base +sidebarTitle: base --- -# `fastmcp.resources.resource` +# `fastmcp.resources.base` Base classes and interfaces for FastMCP resources. ## Classes -### `ResourceContent` +### `ResourceContent` Wrapper for resource content with optional MIME type and metadata. @@ -21,7 +21,7 @@ other types (dict, list, BaseModel, etc.) are automatically JSON-serialized. **Methods:** -#### `to_mcp_resource_contents` +#### `to_mcp_resource_contents` ```python to_mcp_resource_contents(self, uri: AnyUrl | str) -> mcp.types.TextResourceContents | mcp.types.BlobResourceContents @@ -36,7 +36,7 @@ Convert to MCP resource contents type. - TextResourceContents for str content, BlobResourceContents for bytes -### `ResourceResult` +### `ResourceResult` Canonical result type for resource reads. @@ -47,7 +47,7 @@ per-item MIME types, and metadata at both the item and result level. **Methods:** -#### `to_mcp_result` +#### `to_mcp_result` ```python to_mcp_result(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult @@ -62,7 +62,7 @@ Convert to MCP ReadResourceResult. - MCP ReadResourceResult with converted contents -### `Resource` +### `Resource` Base class for all resources. @@ -70,13 +70,13 @@ Base class for all resources. **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl) -> FunctionResource ``` -#### `set_default_mime_type` +#### `set_default_mime_type` ```python set_default_mime_type(cls, mime_type: str | None) -> str @@ -85,7 +85,7 @@ set_default_mime_type(cls, mime_type: str | None) -> str Set default MIME type if not provided. -#### `set_default_name` +#### `set_default_name` ```python set_default_name(self) -> Self @@ -94,7 +94,7 @@ set_default_name(self) -> Self Set default name from URI if not provided. -#### `read` +#### `read` ```python read(self) -> str | bytes | ResourceResult @@ -108,7 +108,7 @@ Subclasses implement this to return resource data. Supported return types: - ResourceResult: Full control over contents and result-level meta -#### `convert_result` +#### `convert_result` ```python convert_result(self, raw_value: Any) -> ResourceResult @@ -131,7 +131,7 @@ MCP Apps CSP/permissions) is propagated to each content item so that hosts can read it from the ``resources/read`` response. -#### `to_mcp_resource` +#### `to_mcp_resource` ```python to_mcp_resource(self, **overrides: Any) -> SDKResource @@ -140,7 +140,7 @@ to_mcp_resource(self, **overrides: Any) -> SDKResource Convert the resource to an SDKResource. -#### `key` +#### `key` ```python key(self) -> str @@ -149,7 +149,7 @@ key(self) -> str The globally unique lookup key for this resource. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -158,7 +158,7 @@ register_with_docket(self, docket: Docket) -> None Register this resource with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, **kwargs: Any) -> Execution @@ -173,7 +173,7 @@ Schedule this resource for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-resources-function_resource.mdx b/docs/python-sdk/fastmcp-resources-function_resource.mdx index 3a7d346e1..4977f0d58 100644 --- a/docs/python-sdk/fastmcp-resources-function_resource.mdx +++ b/docs/python-sdk/fastmcp-resources-function_resource.mdx @@ -10,7 +10,7 @@ Standalone @resource decorator for FastMCP. ## Functions -### `resource` +### `resource` ```python resource(uri: str) -> Callable[[F], F] @@ -25,19 +25,19 @@ using mcp.add_resource(). ## Classes -### `DecoratedResource` +### `DecoratedResource` Protocol for functions decorated with @resource. -### `ResourceMeta` +### `ResourceMeta` Metadata attached to functions by the @resource decorator. -### `FunctionResource` +### `FunctionResource` A resource that defers data loading by wrapping a function. @@ -54,7 +54,7 @@ The function can return: **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl | None = None) -> FunctionResource @@ -71,7 +71,7 @@ individual parameters must not be passed. Cannot be used together with metadata parameter. -#### `read` +#### `read` ```python read(self) -> str | bytes | ResourceResult @@ -80,7 +80,7 @@ read(self) -> str | bytes | ResourceResult Read the resource by calling the wrapped function. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None diff --git a/docs/python-sdk/fastmcp-resources-template.mdx b/docs/python-sdk/fastmcp-resources-template.mdx index 89e51c22f..06d8ef8ed 100644 --- a/docs/python-sdk/fastmcp-resources-template.mdx +++ b/docs/python-sdk/fastmcp-resources-template.mdx @@ -10,7 +10,7 @@ Resource template functionality. ## Functions -### `extract_query_params` +### `extract_query_params` ```python extract_query_params(uri_template: str) -> set[str] @@ -20,10 +20,10 @@ extract_query_params(uri_template: str) -> set[str] Extract query parameter names from RFC 6570 `{?param1,param2}` syntax. -### `build_regex` +### `build_regex` ```python -build_regex(template: str) -> re.Pattern +build_regex(template: str) -> re.Pattern[str] | None ``` @@ -34,8 +34,11 @@ Supports: - `{var*}` - wildcard path parameter (captures multiple segments) - `{?var1,var2}` - query parameters (ignored in path matching) +Returns None if the template produces an invalid regex (e.g. parameter +names with hyphens, leading digits, or duplicates from a remote server). -### `match_uri_template` + +### `match_uri_template` ```python match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None @@ -51,7 +54,7 @@ Supports RFC 6570 URI templates: ## Classes -### `ResourceTemplate` +### `ResourceTemplate` A template for dynamically creating resources. @@ -59,13 +62,13 @@ A template for dynamically creating resources. **Methods:** -#### `from_function` +#### `from_function` ```python from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None) -> FunctionResourceTemplate ``` -#### `set_default_mime_type` +#### `set_default_mime_type` ```python set_default_mime_type(cls, mime_type: str | None) -> str @@ -74,7 +77,7 @@ set_default_mime_type(cls, mime_type: str | None) -> str Set default MIME type if not provided. -#### `matches` +#### `matches` ```python matches(self, uri: str) -> dict[str, Any] | None @@ -83,7 +86,7 @@ matches(self, uri: str) -> dict[str, Any] | None Check if URI matches template and extract parameters. -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult @@ -92,7 +95,7 @@ read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult Read the resource content. -#### `convert_result` +#### `convert_result` ```python convert_result(self, raw_value: Any) -> ResourceResult @@ -108,7 +111,7 @@ Handles ResourceResult passthrough and converts raw values using ResourceResult's normalization. -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource @@ -120,7 +123,7 @@ The base implementation does not support background tasks. Use FunctionResourceTemplate for task support. -#### `to_mcp_template` +#### `to_mcp_template` ```python to_mcp_template(self, **overrides: Any) -> SDKResourceTemplate @@ -129,7 +132,7 @@ to_mcp_template(self, **overrides: Any) -> SDKResourceTemplate Convert the resource template to an SDKResourceTemplate. -#### `from_mcp_template` +#### `from_mcp_template` ```python from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate @@ -138,7 +141,7 @@ from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object. -#### `key` +#### `key` ```python key(self) -> str @@ -147,7 +150,7 @@ key(self) -> str The globally unique lookup key for this template. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -156,7 +159,7 @@ register_with_docket(self, docket: Docket) -> None Register this template with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution @@ -172,13 +175,13 @@ Schedule this template for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FunctionResourceTemplate` +### `FunctionResourceTemplate` A template for dynamically creating resources. @@ -186,7 +189,7 @@ A template for dynamically creating resources. **Methods:** -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource @@ -195,7 +198,7 @@ create_resource(self, uri: str, params: dict[str, Any]) -> Resource Create a resource from the template with the given parameters. -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult @@ -204,7 +207,7 @@ read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult Read the resource content. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -216,7 +219,7 @@ FunctionResourceTemplate registers the underlying function, which has the user's Depends parameters for docket to resolve. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution @@ -234,7 +237,7 @@ FunctionResourceTemplate splats the params dict since .fn expects **kwargs. - `**kwargs`: Additional kwargs passed to docket.add() -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None) -> FunctionResourceTemplate diff --git a/docs/python-sdk/fastmcp-resources-types.mdx b/docs/python-sdk/fastmcp-resources-types.mdx index d19b24eb2..5a951f0dc 100644 --- a/docs/python-sdk/fastmcp-resources-types.mdx +++ b/docs/python-sdk/fastmcp-resources-types.mdx @@ -54,7 +54,7 @@ Set is_binary=True to read file as binary data instead of text. **Methods:** -#### `validate_absolute_path` +#### `validate_absolute_path` ```python validate_absolute_path(cls, path: Path) -> Path @@ -63,7 +63,7 @@ validate_absolute_path(cls, path: Path) -> Path Ensure path is absolute. -#### `set_binary_from_mime_type` +#### `set_binary_from_mime_type` ```python set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool @@ -72,7 +72,7 @@ set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool Set is_binary based on mime_type if not explicitly set. -#### `read` +#### `read` ```python read(self) -> ResourceResult @@ -81,7 +81,7 @@ read(self) -> ResourceResult Read the file content. -### `HttpResource` +### `HttpResource` A resource that reads from an HTTP endpoint. @@ -89,7 +89,7 @@ A resource that reads from an HTTP endpoint. **Methods:** -#### `read` +#### `read` ```python read(self) -> ResourceResult @@ -98,7 +98,7 @@ read(self) -> ResourceResult Read the HTTP content. -### `DirectoryResource` +### `DirectoryResource` A resource that lists files in a directory. @@ -106,7 +106,7 @@ A resource that lists files in a directory. **Methods:** -#### `validate_absolute_path` +#### `validate_absolute_path` ```python validate_absolute_path(cls, path: Path) -> Path @@ -115,7 +115,7 @@ validate_absolute_path(cls, path: Path) -> Path Ensure path is absolute. -#### `list_files` +#### `list_files` ```python list_files(self) -> list[Path] @@ -124,7 +124,7 @@ list_files(self) -> list[Path] List files in the directory. -#### `read` +#### `read` ```python read(self) -> ResourceResult diff --git a/docs/python-sdk/fastmcp-server-app.mdx b/docs/python-sdk/fastmcp-server-app.mdx new file mode 100644 index 000000000..7f99ecb53 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-app.mdx @@ -0,0 +1,13 @@ +--- +title: app +sidebarTitle: app +--- + +# `fastmcp.server.app` + + +Backward-compatible re-exports from fastmcp.apps.app. + +.. deprecated:: 3.2.0 + Import from ``fastmcp.apps.app`` or ``fastmcp`` instead. + diff --git a/docs/python-sdk/fastmcp-server-apps.mdx b/docs/python-sdk/fastmcp-server-apps.mdx index 00e53eafe..df7d64d44 100644 --- a/docs/python-sdk/fastmcp-server-apps.mdx +++ b/docs/python-sdk/fastmcp-server-apps.mdx @@ -6,81 +6,8 @@ sidebarTitle: apps # `fastmcp.server.apps` -MCP Apps support — extension negotiation and typed UI metadata models. +Backward-compatible re-exports from fastmcp.apps. -Provides constants and Pydantic models for the MCP Apps extension -(io.modelcontextprotocol/ui), enabling tools and resources to carry -UI metadata for clients that support interactive app rendering. - - -## Functions - -### `app_config_to_meta_dict` - -```python -app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any] -``` - - -Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``. - - -### `resolve_ui_mime_type` - -```python -resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None -``` - - -Return the appropriate MIME type for a resource URI. - -For ``ui://`` scheme resources, defaults to ``UI_MIME_TYPE`` when no -explicit MIME type is provided. This ensures UI resources are correctly -identified regardless of how they're registered (via FastMCP.resource, -the standalone @resource decorator, or resource templates). - -**Args:** -- `uri`: The resource URI string -- `explicit_mime_type`: The MIME type explicitly provided by the user - -**Returns:** -- The resolved MIME type (explicit value, UI default, or None) - - -## Classes - -### `ResourceCSP` - - -Content Security Policy for MCP App resources. - -Declares which external origins the app is allowed to connect to or -load resources from. Hosts use these declarations to build the -``Content-Security-Policy`` header for the sandboxed iframe. - - -### `ResourcePermissions` - - -Iframe sandbox permissions for MCP App resources. - -Each field, when set (typically to ``{}``), requests that the host -grant the corresponding Permission Policy feature to the sandboxed -iframe. Hosts MAY honour these; apps should use JS feature detection -as a fallback. - - -### `AppConfig` - - -Configuration for MCP App tools and resources. - -Controls how a tool or resource participates in the MCP Apps extension. -On tools, ``resource_uri`` and ``visibility`` specify which UI resource -to render and where the tool appears. On resources, those fields must -be left unset (the resource itself is the UI). - -All fields use ``exclude_none`` serialization so only explicitly-set -values appear on the wire. Aliases match the MCP Apps wire format -(camelCase). +.. deprecated:: 3.2.0 + Import from ``fastmcp.apps`` instead. diff --git a/docs/python-sdk/fastmcp-server-auth-auth.mdx b/docs/python-sdk/fastmcp-server-auth-auth.mdx index 2186df875..def0830fd 100644 --- a/docs/python-sdk/fastmcp-server-auth-auth.mdx +++ b/docs/python-sdk/fastmcp-server-auth-auth.mdx @@ -183,7 +183,7 @@ Get HTTP application-level middleware for this auth provider. - List of Starlette Middleware instances to apply to the HTTP app -### `TokenVerifier` +### `TokenVerifier` Base class for token verifiers (Resource Servers). @@ -194,7 +194,7 @@ Token verifiers typically don't provide authentication routes by default. **Methods:** -#### `scopes_supported` +#### `scopes_supported` ```python scopes_supported(self) -> list[str] @@ -208,7 +208,7 @@ where tokens contain short-form scopes but clients request full URI scopes). -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -217,7 +217,7 @@ verify_token(self, token: str) -> AccessToken | None Verify a bearer token and return access info if valid. -### `RemoteAuthProvider` +### `RemoteAuthProvider` Authentication provider for resource servers that verify tokens from known authorization servers. @@ -234,7 +234,7 @@ the authorization servers that issue valid tokens. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -243,7 +243,7 @@ verify_token(self, token: str) -> AccessToken | None Verify token using the configured token verifier. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -254,7 +254,7 @@ Get routes for this provider. Creates protected resource metadata routes (RFC 9728). -### `MultiAuth` +### `MultiAuth` Composes an optional auth server with additional token verifiers. @@ -270,7 +270,7 @@ come from the server; verifiers contribute only token verification. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -283,7 +283,7 @@ it is logged and treated as a non-match so that remaining sources still get a chance to verify the token. -#### `set_mcp_path` +#### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None @@ -292,7 +292,7 @@ set_mcp_path(self, mcp_path: str | None) -> None Propagate MCP path to the server and all verifiers. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -301,7 +301,7 @@ get_routes(self, mcp_path: str | None = None) -> list[Route] Delegate route creation to the server. -#### `get_well_known_routes` +#### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] @@ -313,7 +313,7 @@ This ensures that server-specific well-known route logic (e.g., OAuthProvider's RFC 8414 path-aware discovery) is preserved. -### `OAuthProvider` +### `OAuthProvider` OAuth Authorization Server provider. @@ -324,7 +324,7 @@ authorization flows, token issuance, and token verification. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -342,7 +342,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -358,7 +358,7 @@ This method creates the full set of OAuth routes including: - List of OAuth routes -#### `get_well_known_routes` +#### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-cimd.mdx b/docs/python-sdk/fastmcp-server-auth-cimd.mdx index dda5d28c6..7cb0dabdc 100644 --- a/docs/python-sdk/fastmcp-server-auth-cimd.mdx +++ b/docs/python-sdk/fastmcp-server-auth-cimd.mdx @@ -129,6 +129,9 @@ validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool Validate that a redirect_uri is allowed by the CIMD document. +Uses component-level matching (scheme, host, port, path) which correctly +handles RFC 8252 §7.3 loopback port flexibility and wildcard patterns. + **Args:** - `doc`: The CIMD document - `redirect_uri`: The redirect URI to validate @@ -137,7 +140,7 @@ Validate that a redirect_uri is allowed by the CIMD document. - True if valid, False otherwise -### `CIMDAssertionValidator` +### `CIMDAssertionValidator` Validates JWT assertions for private_key_jwt CIMD clients. @@ -153,7 +156,7 @@ JTI replay protection uses TTL-based caching to ensure proper security: **Methods:** -#### `validate_assertion` +#### `validate_assertion` ```python validate_assertion(self, assertion: str, client_id: str, token_endpoint: str, cimd_doc: CIMDDocument) -> bool @@ -174,7 +177,7 @@ Validate JWT assertion from client. - `ValueError`: If validation fails -### `CIMDClientManager` +### `CIMDClientManager` Manages all CIMD client operations for OAuth proxy. @@ -191,7 +194,7 @@ single, focused manager class. **Methods:** -#### `is_cimd_client_id` +#### `is_cimd_client_id` ```python is_cimd_client_id(self, client_id: str) -> bool @@ -206,7 +209,7 @@ Check if client_id is a CIMD URL. - True if client_id is an HTTPS URL (CIMD format) -#### `get_client` +#### `get_client` ```python get_client(self, client_id_url: str) @@ -221,7 +224,7 @@ Fetch CIMD document and create synthetic OAuth client. - OAuthProxyClient with CIMD document attached, or None if fetch fails -#### `validate_private_key_jwt` +#### `validate_private_key_jwt` ```python validate_private_key_jwt(self, assertion: str, client, token_endpoint: str) -> bool diff --git a/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx b/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx index b30f3090b..9ca0d77ad 100644 --- a/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx +++ b/docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx @@ -15,7 +15,7 @@ This maintains proper OAuth 2.0 token audience boundaries. ## Functions -### `derive_jwt_key` +### `derive_jwt_key` ```python derive_jwt_key() -> bytes @@ -27,7 +27,7 @@ Derive JWT signing key from a high-entropy or low-entropy key material and serve ## Classes -### `JWTIssuer` +### `JWTIssuer` Issues and validates FastMCP-signed JWT tokens using HS256. @@ -39,7 +39,7 @@ a key derived from the upstream client secret. **Methods:** -#### `issue_access_token` +#### `issue_access_token` ```python issue_access_token(self, client_id: str, scopes: list[str], jti: str, expires_in: int = 3600, upstream_claims: dict[str, Any] | None = None) -> str @@ -62,7 +62,7 @@ which contains actual user identity and authorization data. - Signed JWT token -#### `issue_refresh_token` +#### `issue_refresh_token` ```python issue_refresh_token(self, client_id: str, scopes: list[str], jti: str, expires_in: int, upstream_claims: dict[str, Any] | None = None) -> str @@ -85,18 +85,20 @@ token which contains actual user identity and authorization data. - Signed JWT token -#### `verify_token` +#### `verify_token` ```python -verify_token(self, token: str) -> dict[str, Any] +verify_token(self, token: str, expected_token_use: str = 'access') -> dict[str, Any] ``` Verify and decode a FastMCP token. -Validates JWT signature, expiration, issuer, and audience. +Validates JWT signature, expiration, issuer, audience, and token type. **Args:** - `token`: JWT token to verify +- `expected_token_use`: Expected token type ("access" or "refresh"). +Defaults to "access", which rejects refresh tokens. **Returns:** - Decoded token payload diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx index dd1400086..2f7742f26 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx @@ -26,7 +26,7 @@ production use with enterprise identity providers. ## Classes -### `OAuthProxy` +### `OAuthProxy` OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. @@ -140,7 +140,7 @@ Handles provider-specific requirements: **Methods:** -#### `set_mcp_path` +#### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None @@ -157,7 +157,7 @@ this specific MCP endpoint. - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -#### `jwt_issuer` +#### `jwt_issuer` ```python jwt_issuer(self) -> JWTIssuer @@ -169,7 +169,7 @@ The JWT issuer is created when set_mcp_path() is called (via get_routes()). This property ensures a clear error if used before initialization. -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -182,7 +182,7 @@ For unregistered clients, returns None (which will raise an error in the SDK). CIMD clients (URL-based client IDs) are looked up and cached automatically. -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -196,7 +196,7 @@ redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -214,7 +214,7 @@ If consent is disabled (require_authorization_consent=False), skip the consent s and redirect directly to the upstream IdP. -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -226,7 +226,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -244,7 +244,7 @@ Implements the token factory pattern: PKCE validation is handled by the MCP framework before this method is called. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -256,7 +256,7 @@ Looks up by token hash and reconstructs the RefreshToken object. Validates that the token belongs to the requesting client. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -273,7 +273,7 @@ Implements two-tier refresh: 6. Keep same FastMCP refresh token (unless upstream rotates) -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -286,13 +286,14 @@ This implements the token swap pattern: 2. Look up upstream token via JTI mapping 3. Decrypt upstream token 4. Validate upstream token with provider (GitHub API, JWT validation, etc.) -5. Return upstream validation result +5. If upstream validation fails, attempt transparent refresh +6. Return upstream validation result The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -305,7 +306,7 @@ For all tokens, attempts upstream revocation if endpoint is configured. Access token JTI mappings expire via TTL. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx index 183380ed9..a9160db17 100644 --- a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx @@ -19,7 +19,7 @@ This implementation is based on: ## Classes -### `OIDCConfiguration` +### `OIDCConfiguration` OIDC Configuration. @@ -27,7 +27,7 @@ OIDC Configuration. **Methods:** -#### `get_oidc_configuration` +#### `get_oidc_configuration` ```python get_oidc_configuration(cls, config_url: AnyHttpUrl) -> Self @@ -41,7 +41,7 @@ Get the OIDC configuration for the specified config URL. - `timeout_seconds`: HTTP request timeout in seconds -### `OIDCProxy` +### `OIDCProxy` OAuth provider that wraps OAuthProxy to provide configuration via an OIDC configuration URL. @@ -52,7 +52,7 @@ that is OIDC compliant. **Methods:** -#### `get_oidc_configuration` +#### `get_oidc_configuration` ```python get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration @@ -66,7 +66,7 @@ Gets the OIDC configuration for the specified configuration URL. - `timeout_seconds`: HTTP request timeout in seconds -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx b/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx index 140ddd193..350d3f2e6 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx @@ -31,7 +31,7 @@ Example: ## Classes -### `Auth0Provider` +### `Auth0Provider` An Auth0 provider implementation for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx index 5803d4f62..c8d9ea795 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx @@ -31,7 +31,7 @@ Example: ## Classes -### `AWSCognitoTokenVerifier` +### `AWSCognitoTokenVerifier` Token verifier that filters claims to Cognito-specific subset. @@ -39,7 +39,7 @@ Token verifier that filters claims to Cognito-specific subset. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -48,7 +48,7 @@ verify_token(self, token: str) -> AccessToken | None Verify token and filter claims to Cognito-specific subset. -### `AWSCognitoProvider` +### `AWSCognitoProvider` Complete AWS Cognito OAuth provider for FastMCP. @@ -66,10 +66,10 @@ Features: **Methods:** -#### `get_token_verifier` +#### `get_token_verifier` ```python -get_token_verifier(self) -> TokenVerifier +get_token_verifier(self) -> AWSCognitoTokenVerifier ``` Creates a Cognito-specific token verifier with claim filtering. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index 4d3140c4a..6301e4c28 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -14,7 +14,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. ## Functions -### `EntraOBOToken` +### `EntraOBOToken` ```python EntraOBOToken(scopes: list[str]) -> str @@ -43,7 +43,7 @@ or OBO exchange fails ## Classes -### `AzureProvider` +### `AzureProvider` Azure (Microsoft Entra) OAuth provider for FastMCP. @@ -78,7 +78,7 @@ Setup: **Methods:** -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -98,7 +98,7 @@ scopes to determine the resource/audience instead of a separate parameter. - Authorization URL to redirect the user to Azure AD -#### `get_obo_credential` +#### `get_obo_credential` ```python get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential @@ -120,7 +120,7 @@ calls multiple tools with the same scopes. - `ImportError`: If azure-identity is not installed (requires fastmcp[azure]). -#### `close_obo_credentials` +#### `close_obo_credentials` ```python close_obo_credentials(self) -> None @@ -129,7 +129,7 @@ close_obo_credentials(self) -> None Close all cached OBO credentials. -### `AzureJWTVerifier` +### `AzureJWTVerifier` JWT verifier pre-configured for Azure AD / Microsoft Entra ID. @@ -166,7 +166,7 @@ Example:: **Methods:** -#### `scopes_supported` +#### `scopes_supported` ```python scopes_supported(self) -> list[str] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-clerk.mdx b/docs/python-sdk/fastmcp-server-auth-providers-clerk.mdx new file mode 100644 index 000000000..5add0fff2 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-providers-clerk.mdx @@ -0,0 +1,94 @@ +--- +title: clerk +sidebarTitle: clerk +--- + +# `fastmcp.server.auth.providers.clerk` + + +Clerk OAuth provider for FastMCP. + +This module provides a complete Clerk OAuth integration that's ready to use +with a Clerk domain, client ID, and client secret. It handles all the complexity +of Clerk's OAuth/OIDC flow, token validation, and user management. + +Clerk uses standard OIDC endpoints derived from the instance domain +(e.g., ``https://.clerk.accounts.dev``). Token verification is +performed via the introspection endpoint (RFC 7662) for security-critical +checks (active status, audience, scopes), followed by the userinfo endpoint +for profile enrichment. Userinfo failure is non-fatal. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.clerk import ClerkProvider + + auth = ClerkProvider( + domain="saving-primate-16.clerk.accounts.dev", + client_id="your-clerk-client-id", + client_secret="your-clerk-client-secret", + base_url="https://my-server.com", + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` + + +## Classes + +### `ClerkTokenVerifier` + + +Token verifier for Clerk OAuth tokens. + +Clerk issues standard OIDC tokens. Verification uses the introspection +endpoint (RFC 7662) as the primary security gate — it confirms the token +is active and provides metadata (scopes, expiry, audience). The userinfo +endpoint is called second for profile enrichment (name, email, picture) +and its failure is non-fatal. + +When a ``client_id`` is configured, the audience from introspection is +validated against it. When ``required_scopes`` are configured, +introspection must return the token's scopes — the verifier will not +assume scopes when introspection is unavailable. + + +**Methods:** + +#### `verify_token` + +```python +verify_token(self, token: str) -> AccessToken | None +``` + +Verify a Clerk OAuth token via introspection and userinfo. + +Calls the introspection endpoint first to validate the token and +retrieve auth metadata (active status, scopes, expiry, audience). +If the token passes security checks, the userinfo endpoint is called +for profile enrichment. Userinfo failure is non-fatal. + +When a ``client_id`` is configured, the token's audience must match it. +When ``required_scopes`` are configured, introspection must confirm +them; tokens are rejected if scope information is unavailable. + + +### `ClerkProvider` + + +Complete Clerk OAuth provider for FastMCP. + +This provider makes it trivial to add Clerk OAuth protection to any +FastMCP server. Provide your Clerk instance domain, OAuth app credentials, +and a base URL, and you're ready to go. + +Clerk uses standard OIDC endpoints derived from the instance domain. +All endpoint URLs are constructed automatically from the domain parameter. + +Features: +- Transparent OAuth proxy to Clerk +- Automatic token validation via Clerk's userinfo & introspection APIs +- User information extraction from Clerk's OIDC claims +- PKCE support (S256) +- Minimal configuration required + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx index 3436aa5c3..45dce934e 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx @@ -43,7 +43,7 @@ https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps# **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-discord.mdx b/docs/python-sdk/fastmcp-server-auth-providers-discord.mdx index 61b024b63..57d3b743b 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-discord.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-discord.mdx @@ -29,7 +29,7 @@ Example: ## Classes -### `DiscordTokenVerifier` +### `DiscordTokenVerifier` Token verifier for Discord OAuth tokens. @@ -40,7 +40,7 @@ by calling Discord's tokeninfo API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -49,7 +49,7 @@ verify_token(self, token: str) -> AccessToken | None Verify Discord OAuth token by calling Discord's tokeninfo API. -### `DiscordProvider` +### `DiscordProvider` Complete Discord OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx index 66a808136..48d8e24da 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx @@ -29,7 +29,7 @@ Example: ## Classes -### `GitHubTokenVerifier` +### `GitHubTokenVerifier` Token verifier for GitHub OAuth tokens. @@ -37,10 +37,14 @@ Token verifier for GitHub OAuth tokens. GitHub OAuth tokens are opaque (not JWTs), so we verify them by calling GitHub's API to check if they're valid and get user info. +Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive +integer to cache successful verification results and avoid repeated +GitHub API calls for the same token. + **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -49,7 +53,7 @@ verify_token(self, token: str) -> AccessToken | None Verify GitHub OAuth token by calling GitHub API. -### `GitHubProvider` +### `GitHubProvider` Complete GitHub OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx index 880488438..05f2400b0 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx @@ -29,27 +29,35 @@ Example: ## Classes -### `GoogleTokenVerifier` +### `GoogleTokenVerifier` Token verifier for Google OAuth tokens. -Google OAuth tokens are opaque (not JWTs), so we verify them -by calling Google's tokeninfo API to check if they're valid and get user info. +Google OAuth tokens are opaque (not JWTs), so we verify them by calling +Google's tokeninfo endpoint with the access token as a query parameter. +This returns the OAuth app ID (``aud``), granted scopes, and expiry time. +User profile data (name, picture, etc.) is fetched separately from the +v2 userinfo endpoint when the token is valid. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` -Verify Google OAuth token by calling Google's tokeninfo API. +Verify a Google OAuth token using the tokeninfo endpoint. + +Calls ``https://oauth2.googleapis.com/tokeninfo?access_token=TOKEN`` +to validate the token and retrieve the OAuth app ID (``aud``), granted +scopes, and expiry time. On success, fetches user profile data from +the v2 userinfo endpoint to populate name, picture, and locale claims. -### `GoogleProvider` +### `GoogleProvider` Complete Google OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-introspection.mdx b/docs/python-sdk/fastmcp-server-auth-providers-introspection.mdx index 811737e34..8666cc726 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-introspection.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-introspection.mdx @@ -31,7 +31,7 @@ Example: ## Classes -### `IntrospectionTokenVerifier` +### `IntrospectionTokenVerifier` OAuth 2.0 Token Introspection verifier (RFC 7662). @@ -59,7 +59,7 @@ introspection endpoint (e.g., ``cache_ttl_seconds=300`` for 5 minutes). **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None diff --git a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx index 6ba9054c2..b049a4d64 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx @@ -60,7 +60,7 @@ Generate a test JWT token for testing purposes. - `kid`: Key ID to include in header -### `JWTVerifier` +### `JWTVerifier` JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. @@ -82,7 +82,7 @@ Use this when: **Methods:** -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -97,7 +97,7 @@ Validate a JWT bearer token and return an AccessToken when the token is valid. - AccessToken | None: An AccessToken populated from token claims if the token is valid; `None` if the token is expired, has an invalid signature or format, fails issuer/audience/scope validation, or any other validation error occurs. -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -115,7 +115,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -### `StaticTokenVerifier` +### `StaticTokenVerifier` Simple static token verifier for testing and development. @@ -136,7 +136,7 @@ WARNING: Never use this in production - tokens are stored in plain text! **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None diff --git a/docs/python-sdk/fastmcp-server-auth-providers-oci.mdx b/docs/python-sdk/fastmcp-server-auth-providers-oci.mdx index b88c7c49e..9f1be4fc1 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-oci.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-oci.mdx @@ -87,7 +87,7 @@ Example: ## Classes -### `OCIProvider` +### `OCIProvider` An OCI IAM Domain provider implementation for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-propelauth.mdx b/docs/python-sdk/fastmcp-server-auth-providers-propelauth.mdx index 3b31b00d8..df066f733 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-propelauth.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-propelauth.mdx @@ -43,7 +43,7 @@ https://docs.propelauth.com/mcp-authentication/overview **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -59,7 +59,7 @@ and creates an authorization server metadata route that forwards to PropelAuth's This is used to advertise the resource URL in metadata. -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None diff --git a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx index 1aa125c6c..cefe81c23 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx @@ -44,7 +44,7 @@ https://docs.scalekit.com/mcp/overview/ **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx b/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx index c44deecae..45f576a8a 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx @@ -29,7 +29,7 @@ IMPORTANT SETUP REQUIREMENTS: 1. Supabase Project Setup: - Create a Supabase project at https://supabase.com - Note your project URL (e.g., "https://abc123.supabase.co") - - Configure your JWT algorithm in Supabase Auth settings (HS256, RS256, or ES256) + - Configure your JWT algorithm in Supabase Auth settings (RS256 or ES256) - Asymmetric keys (RS256/ES256) are recommended for production 2. JWT Verification: @@ -50,7 +50,7 @@ https://supabase.com/docs/guides/auth/jwts **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx index cb263d9ec..f93a9f0c6 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx @@ -18,7 +18,7 @@ Choose based on your WorkOS setup and authentication requirements. ## Classes -### `WorkOSTokenVerifier` +### `WorkOSTokenVerifier` Token verifier for WorkOS OAuth tokens. @@ -29,7 +29,7 @@ the /oauth2/userinfo endpoint to check validity and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -38,7 +38,7 @@ verify_token(self, token: str) -> AccessToken | None Verify WorkOS OAuth token by calling userinfo endpoint. -### `WorkOSProvider` +### `WorkOSProvider` Complete WorkOS OAuth provider for FastMCP. @@ -59,7 +59,7 @@ Setup Requirements: 4. Note your Client ID and Client Secret -### `AuthKitProvider` +### `AuthKitProvider` AuthKit metadata provider for DCR (Dynamic Client Registration). @@ -85,7 +85,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx index 65155a160..af57dd145 100644 --- a/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx +++ b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx @@ -14,7 +14,7 @@ protecting against userinfo-based bypass attacks like http://localhost@evil.com. ## Functions -### `matches_allowed_pattern` +### `matches_allowed_pattern` ```python matches_allowed_pattern(uri: str, pattern: str) -> bool @@ -43,7 +43,7 @@ naive string matching (e.g., http://localhost@evil.com). - True if the URI matches the pattern -### `validate_redirect_uri` +### `validate_redirect_uri` ```python validate_redirect_uri(redirect_uri: str | AnyUrl | None, allowed_patterns: list[str] | None) -> bool diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index 58a916faf..78371e393 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -319,7 +319,7 @@ request context) or when the client did not advertise the extension. Example:: - from fastmcp.server.apps import UI_EXTENSION_ID + from fastmcp.apps.config import UI_EXTENSION_ID @mcp.tool async def my_tool(ctx: Context) -> str: diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx index 60439e182..80b9f8421 100644 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ b/docs/python-sdk/fastmcp-server-dependencies.mdx @@ -15,7 +15,7 @@ CurrentWorker) and background task execution require fastmcp[tasks]. ## Functions -### `get_task_context` +### `get_task_context` ```python get_task_context() -> TaskContextInfo | None @@ -31,7 +31,7 @@ Returns None if not running in a task context (e.g., foreground execution). - TaskContextInfo with task_id and session_id, or None if not in a task. -### `register_task_session` +### `register_task_session` ```python register_task_session(session_id: str, session: ServerSession) -> None @@ -49,7 +49,7 @@ client disconnects. - `session`: The ServerSession instance -### `get_task_session` +### `get_task_session` ```python get_task_session(session_id: str) -> ServerSession | None @@ -65,7 +65,24 @@ Get a registered session by ID if still alive. - The ServerSession if found and alive, None otherwise -### `is_docket_available` +### `register_task_server` + +```python +register_task_server(task_id: str, server: FastMCP) -> None +``` + + +Register the server for a background task. + +Called at task-submission time (inside the child server's call_tool +context) so that background workers can resolve CurrentFastMCP() and +ctx.fastmcp to the child server for mounted tasks. + +The map is bounded to avoid unbounded growth in long-lived servers. +Evicted entries fall back to the ContextVar (parent server). + + +### `is_docket_available` ```python is_docket_available() -> bool @@ -75,7 +92,7 @@ is_docket_available() -> bool Check if pydocket is installed. -### `require_docket` +### `require_docket` ```python require_docket(feature: str) -> None @@ -89,7 +106,7 @@ Raise ImportError with install instructions if docket not available. "CurrentDocket()"). Will be included in the error message. -### `transform_context_annotations` +### `transform_context_annotations` ```python transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any] @@ -115,7 +132,7 @@ allows them to have defaults in any order. - Function with modified signature (same function object, updated __signature__) -### `get_context` +### `get_context` ```python get_context() -> Context @@ -125,7 +142,7 @@ get_context() -> Context Get the current FastMCP Context instance directly. -### `get_server` +### `get_server` ```python get_server() -> FastMCP @@ -134,6 +151,10 @@ get_server() -> FastMCP Get the current FastMCP server instance directly. +In a background-task worker, checks the task-server map first so that +mounted-child tasks resolve to the child server (not the parent that +started the worker). + **Returns:** - The active FastMCP server @@ -141,7 +162,7 @@ Get the current FastMCP server instance directly. - `RuntimeError`: If no server in context -### `get_http_request` +### `get_http_request` ```python get_http_request() -> Request @@ -151,9 +172,11 @@ get_http_request() -> Request Get the current HTTP request. Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. +In background tasks, returns a synthetic request populated with the +snapshotted headers from the originating HTTP request. -### `get_http_headers` +### `get_http_headers` ```python get_http_headers(include_all: bool = False, include: set[str] | None = None) -> dict[str, str] @@ -174,7 +197,7 @@ normally be excluded. This is useful for proxy transports that need to forward authorization headers to upstream MCP servers. -### `get_access_token` +### `get_access_token` ```python get_access_token() -> AccessToken | None @@ -193,7 +216,7 @@ token snapshot stored in Redis at task submission time. - The access token if an authenticated user is available, None otherwise. -### `without_injected_parameters` +### `without_injected_parameters` ```python without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any] @@ -218,7 +241,7 @@ Handles: - Async wrapper function without injected parameters -### `resolve_dependencies` +### `resolve_dependencies` ```python resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None] @@ -244,7 +267,7 @@ time, so all injection goes through the unified DI system. which will be filtered out) -### `CurrentContext` +### `CurrentContext` ```python CurrentContext() -> Context @@ -263,7 +286,7 @@ current MCP operation (tool/resource/prompt call). - `RuntimeError`: If no active context found (during resolution) -### `OptionalCurrentContext` +### `OptionalCurrentContext` ```python OptionalCurrentContext() -> Context | None @@ -273,7 +296,7 @@ OptionalCurrentContext() -> Context | None Get the current FastMCP Context, or None when no context is active. -### `CurrentDocket` +### `CurrentDocket` ```python CurrentDocket() -> Docket @@ -293,7 +316,7 @@ automatically creates for background task scheduling. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentWorker` +### `CurrentWorker` ```python CurrentWorker() -> Worker @@ -313,7 +336,7 @@ automatically creates for background task processing. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentFastMCP` +### `CurrentFastMCP` ```python CurrentFastMCP() -> FastMCP @@ -331,7 +354,7 @@ This dependency provides access to the active FastMCP server. - `RuntimeError`: If no server in context (during resolution) -### `CurrentRequest` +### `CurrentRequest` ```python CurrentRequest() -> Request @@ -351,7 +374,7 @@ current HTTP request. Only available when running over HTTP transports - `RuntimeError`: If no HTTP request in context (e.g., STDIO transport) -### `CurrentHeaders` +### `CurrentHeaders` ```python CurrentHeaders() -> dict[str, str] @@ -369,7 +392,7 @@ transport. - A dependency that resolves to a dictionary of header name -> value -### `CurrentAccessToken` +### `CurrentAccessToken` ```python CurrentAccessToken() -> AccessToken @@ -388,7 +411,7 @@ authenticated request. Raises an error if no authentication is present. - `RuntimeError`: If no authenticated user (use get_access_token() for optional) -### `TokenClaim` +### `TokenClaim` ```python TokenClaim(name: str) -> str @@ -413,7 +436,7 @@ without needing the full token object. ## Classes -### `TaskContextInfo` +### `TaskContextInfo` Information about the current background task context. @@ -422,7 +445,7 @@ Returned by ``get_task_context()`` when running inside a Docket worker. Contains identifiers needed to communicate with the MCP session. -### `ProgressLike` +### `ProgressLike` Protocol for progress tracking interface. @@ -433,7 +456,7 @@ and Docket's Progress (worker context). **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None @@ -442,7 +465,7 @@ current(self) -> int | None Current progress value. -#### `total` +#### `total` ```python total(self) -> int @@ -451,7 +474,7 @@ total(self) -> int Total/target progress value. -#### `message` +#### `message` ```python message(self) -> str | None @@ -460,7 +483,7 @@ message(self) -> str | None Current progress message. -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -469,7 +492,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -478,7 +501,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -487,7 +510,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `InMemoryProgress` +### `InMemoryProgress` In-memory progress tracker for immediate tool execution. @@ -499,25 +522,25 @@ progress doesn't need to be observable across processes. **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None ``` -#### `total` +#### `total` ```python total(self) -> int ``` -#### `message` +#### `message` ```python message(self) -> str | None ``` -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -526,7 +549,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -535,7 +558,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -544,7 +567,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `Progress` +### `Progress` FastMCP Progress dependency that works in both server and worker contexts. @@ -561,7 +584,7 @@ is installed. **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None @@ -570,7 +593,7 @@ current(self) -> int | None Current progress value. -#### `total` +#### `total` ```python total(self) -> int @@ -579,7 +602,7 @@ total(self) -> int Total/target progress value. -#### `message` +#### `message` ```python message(self) -> str | None @@ -588,7 +611,7 @@ message(self) -> str | None Current progress message. -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -597,7 +620,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -606,7 +629,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None diff --git a/docs/python-sdk/fastmcp-server-http.mdx b/docs/python-sdk/fastmcp-server-http.mdx index 9b2fe758d..9b4c309af 100644 --- a/docs/python-sdk/fastmcp-server-http.mdx +++ b/docs/python-sdk/fastmcp-server-http.mdx @@ -32,7 +32,7 @@ Create a base Starlette app with common middleware and routes. - A Starlette application -### `create_sse_app` +### `create_sse_app` ```python create_sse_app(server: FastMCP[LifespanResultT], message_path: str, sse_path: str, auth: AuthProvider | None = None, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan @@ -54,7 +54,7 @@ Returns: A Starlette application with RequestContextMiddleware -### `create_streamable_http_app` +### `create_streamable_http_app` ```python create_streamable_http_app(server: FastMCP[LifespanResultT], streamable_http_path: str, event_store: EventStore | None = None, retry_interval: int | None = None, auth: AuthProvider | None = None, json_response: bool = False, stateless_http: bool = False, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan diff --git a/docs/python-sdk/fastmcp-server-low_level.mdx b/docs/python-sdk/fastmcp-server-low_level.mdx index 78acc7225..7456a5940 100644 --- a/docs/python-sdk/fastmcp-server-low_level.mdx +++ b/docs/python-sdk/fastmcp-server-low_level.mdx @@ -15,7 +15,7 @@ ServerSession that routes initialization requests through FastMCP middleware. **Methods:** -#### `fastmcp` +#### `fastmcp` ```python fastmcp(self) -> FastMCP @@ -24,7 +24,7 @@ fastmcp(self) -> FastMCP Get the FastMCP instance. -#### `client_supports_extension` +#### `client_supports_extension` ```python client_supports_extension(self, extension_id: str) -> bool @@ -36,11 +36,11 @@ Inspects the ``extensions`` extra field on ``ClientCapabilities`` sent by the client during initialization. -### `LowLevelServer` +### `LowLevelServer` **Methods:** -#### `fastmcp` +#### `fastmcp` ```python fastmcp(self) -> FastMCP @@ -49,13 +49,13 @@ fastmcp(self) -> FastMCP Get the FastMCP instance. -#### `create_initialization_options` +#### `create_initialization_options` ```python create_initialization_options(self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, **kwargs: Any) -> InitializationOptions ``` -#### `get_capabilities` +#### `get_capabilities` ```python get_capabilities(self, notification_options: NotificationOptions, experimental_capabilities: dict[str, dict[str, Any]]) -> mcp.types.ServerCapabilities @@ -68,7 +68,7 @@ capabilities.experimental.tasks, which is required by the MCP spec and enables proper task detection by clients like VS Code Copilot 1.107+. -#### `run` +#### `run` ```python run(self, read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], write_stream: MemoryObjectSendStream[SessionMessage], initialization_options: InitializationOptions, raise_exceptions: bool = False, stateless: bool = False) @@ -77,7 +77,7 @@ run(self, read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], wr Overrides the run method to use the MiddlewareServerSession. -#### `read_resource` +#### `read_resource` ```python read_resource(self) -> Callable[[Callable[[AnyUrl], Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult]]], Callable[[AnyUrl], Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult]]] @@ -92,7 +92,7 @@ This decorator can be removed once the MCP SDK adds native CreateTaskResult supp for resources. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self) -> Callable[[Callable[[str, dict[str, Any] | None], Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult]]], Callable[[str, dict[str, Any] | None], Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult]]] diff --git a/docs/python-sdk/fastmcp-server-middleware-caching.mdx b/docs/python-sdk/fastmcp-server-middleware-caching.mdx index 66b86999d..fbf9bba15 100644 --- a/docs/python-sdk/fastmcp-server-middleware-caching.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-caching.mdx @@ -10,13 +10,13 @@ A middleware for response caching. ## Classes -### `CachableResourceContent` +### `CachableResourceContent` A wrapper for ResourceContent that can be cached. -### `CachableResourceResult` +### `CachableResourceResult` A wrapper for ResourceResult that can be cached. @@ -24,47 +24,47 @@ A wrapper for ResourceResult that can be cached. **Methods:** -#### `get_size` +#### `get_size` ```python get_size(self) -> int ``` -#### `wrap` +#### `wrap` ```python wrap(cls, value: ResourceResult) -> Self ``` -#### `unwrap` +#### `unwrap` ```python unwrap(self) -> ResourceResult ``` -### `CachableToolResult` +### `CachableToolResult` **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, value: ToolResult) -> Self ``` -#### `unwrap` +#### `unwrap` ```python unwrap(self) -> ToolResult ``` -### `CachableMessage` +### `CachableMessage` A wrapper for Message that can be cached. -### `CachablePromptResult` +### `CachablePromptResult` A wrapper for PromptResult that can be cached. @@ -72,69 +72,69 @@ A wrapper for PromptResult that can be cached. **Methods:** -#### `get_size` +#### `get_size` ```python get_size(self) -> int ``` -#### `wrap` +#### `wrap` ```python wrap(cls, value: PromptResult) -> Self ``` -#### `unwrap` +#### `unwrap` ```python unwrap(self) -> PromptResult ``` -### `SharedMethodSettings` +### `SharedMethodSettings` Shared config for a cache method. -### `ListToolsSettings` +### `ListToolsSettings` Configuration options for Tool-related caching. -### `ListResourcesSettings` +### `ListResourcesSettings` Configuration options for Resource-related caching. -### `ListPromptsSettings` +### `ListPromptsSettings` Configuration options for Prompt-related caching. -### `CallToolSettings` +### `CallToolSettings` Configuration options for Tool-related caching. -### `ReadResourceSettings` +### `ReadResourceSettings` Configuration options for Resource-related caching. -### `GetPromptSettings` +### `GetPromptSettings` Configuration options for Prompt-related caching. -### `ResponseCachingStatistics` +### `ResponseCachingStatistics` -### `ResponseCachingMiddleware` +### `ResponseCachingMiddleware` The response caching middleware offers a simple way to cache responses to mcp methods. The Middleware @@ -151,7 +151,7 @@ Notes: **Methods:** -#### `on_list_tools` +#### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext[mcp.types.ListToolsRequest], call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] @@ -161,7 +161,7 @@ List tools from the cache, if caching is enabled, and the result is in the cache otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `on_list_resources` +#### `on_list_resources` ```python on_list_resources(self, context: MiddlewareContext[mcp.types.ListResourcesRequest], call_next: CallNext[mcp.types.ListResourcesRequest, Sequence[Resource]]) -> Sequence[Resource] @@ -171,7 +171,7 @@ List resources from the cache, if caching is enabled, and the result is in the c otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `on_list_prompts` +#### `on_list_prompts` ```python on_list_prompts(self, context: MiddlewareContext[mcp.types.ListPromptsRequest], call_next: CallNext[mcp.types.ListPromptsRequest, Sequence[Prompt]]) -> Sequence[Prompt] @@ -181,7 +181,7 @@ List prompts from the cache, if caching is enabled, and the result is in the cac otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `on_call_tool` +#### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mcp.types.CallToolRequestParams], call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult]) -> ToolResult @@ -191,7 +191,7 @@ Call a tool from the cache, if caching is enabled, and the result is in the cach otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `on_read_resource` +#### `on_read_resource` ```python on_read_resource(self, context: MiddlewareContext[mcp.types.ReadResourceRequestParams], call_next: CallNext[mcp.types.ReadResourceRequestParams, ResourceResult]) -> ResourceResult @@ -201,7 +201,7 @@ Read a resource from the cache, if caching is enabled, and the result is in the otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `on_get_prompt` +#### `on_get_prompt` ```python on_get_prompt(self, context: MiddlewareContext[mcp.types.GetPromptRequestParams], call_next: CallNext[mcp.types.GetPromptRequestParams, PromptResult]) -> PromptResult @@ -211,7 +211,7 @@ Get a prompt from the cache, if caching is enabled, and the result is in the cac otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `statistics` +#### `statistics` ```python statistics(self) -> ResponseCachingStatistics diff --git a/docs/python-sdk/fastmcp-server-middleware-tool_injection.mdx b/docs/python-sdk/fastmcp-server-middleware-tool_injection.mdx index 6c9c01346..549150689 100644 --- a/docs/python-sdk/fastmcp-server-middleware-tool_injection.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-tool_injection.mdx @@ -10,7 +10,7 @@ A middleware for injecting tools into the MCP server context. ## Functions -### `list_prompts` +### `list_prompts` ```python list_prompts(context: Context) -> list[Prompt] @@ -20,7 +20,7 @@ list_prompts(context: Context) -> list[Prompt] List prompts available on the server. -### `get_prompt` +### `get_prompt` ```python get_prompt(context: Context, name: Annotated[str, 'The name of the prompt to render.'], arguments: Annotated[dict[str, Any] | None, 'The arguments to pass to the prompt.'] = None) -> mcp.types.GetPromptResult @@ -30,7 +30,7 @@ get_prompt(context: Context, name: Annotated[str, 'The name of the prompt to ren Render a prompt available on the server. -### `list_resources` +### `list_resources` ```python list_resources(context: Context) -> list[mcp.types.Resource] @@ -40,7 +40,7 @@ list_resources(context: Context) -> list[mcp.types.Resource] List resources available on the server. -### `read_resource` +### `read_resource` ```python read_resource(context: Context, uri: Annotated[AnyUrl | str, 'The URI of the resource to read.']) -> ResourceResult @@ -52,7 +52,7 @@ Read a resource available on the server. ## Classes -### `ToolInjectionMiddleware` +### `ToolInjectionMiddleware` A middleware for injecting tools into the context. @@ -60,7 +60,7 @@ A middleware for injecting tools into the context. **Methods:** -#### `on_list_tools` +#### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext[mcp.types.ListToolsRequest], call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] @@ -69,7 +69,7 @@ on_list_tools(self, context: MiddlewareContext[mcp.types.ListToolsRequest], call Inject tools into the response. -#### `on_call_tool` +#### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mcp.types.CallToolRequestParams], call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult]) -> ToolResult @@ -78,14 +78,20 @@ on_call_tool(self, context: MiddlewareContext[mcp.types.CallToolRequestParams], Intercept tool calls to injected tools. -### `PromptToolMiddleware` +### `PromptToolMiddleware` A middleware for injecting prompts as tools into the context. +.. deprecated:: + Use ``fastmcp.server.transforms.PromptsAsTools`` instead. -### `ResourceToolMiddleware` + +### `ResourceToolMiddleware` A middleware for injecting resources as tools into the context. +.. deprecated:: + Use ``fastmcp.server.transforms.ResourcesAsTools`` instead. + diff --git a/docs/python-sdk/fastmcp-server-mixins-lifespan.mdx b/docs/python-sdk/fastmcp-server-mixins-lifespan.mdx index 9d0eec22e..fe786652c 100644 --- a/docs/python-sdk/fastmcp-server-mixins-lifespan.mdx +++ b/docs/python-sdk/fastmcp-server-mixins-lifespan.mdx @@ -10,7 +10,7 @@ Lifespan and Docket task infrastructure for FastMCP Server. ## Classes -### `LifespanMixin` +### `LifespanMixin` Mixin providing lifespan and Docket task infrastructure for FastMCP. @@ -18,7 +18,7 @@ Mixin providing lifespan and Docket task infrastructure for FastMCP. **Methods:** -#### `docket` +#### `docket` ```python docket(self: FastMCP) -> Docket | None diff --git a/docs/python-sdk/fastmcp-server-mixins-mcp_operations.mdx b/docs/python-sdk/fastmcp-server-mixins-mcp_operations.mdx index 0b6bef529..6fbd2855a 100644 --- a/docs/python-sdk/fastmcp-server-mixins-mcp_operations.mdx +++ b/docs/python-sdk/fastmcp-server-mixins-mcp_operations.mdx @@ -10,7 +10,7 @@ MCP protocol handler setup and wire-format handlers for FastMCP Server. ## Classes -### `MCPOperationsMixin` +### `MCPOperationsMixin` Mixin providing MCP protocol handler setup and wire-format handlers. diff --git a/docs/python-sdk/fastmcp-server-mixins-transport.mdx b/docs/python-sdk/fastmcp-server-mixins-transport.mdx index c5cc4e2fd..ad4e0b60e 100644 --- a/docs/python-sdk/fastmcp-server-mixins-transport.mdx +++ b/docs/python-sdk/fastmcp-server-mixins-transport.mdx @@ -10,7 +10,7 @@ Transport-related methods for FastMCP Server. ## Classes -### `TransportMixin` +### `TransportMixin` Mixin providing transport-related methods for FastMCP. @@ -20,7 +20,7 @@ Includes HTTP/stdio/SSE transport handling and custom HTTP routes. **Methods:** -#### `run_async` +#### `run_async` ```python run_async(self: FastMCP, transport: Transport | None = None, show_banner: bool | None = None, **transport_kwargs: Any) -> None @@ -34,7 +34,7 @@ Run the FastMCP server asynchronously. FASTMCP_SHOW_SERVER_BANNER setting (default\: True). -#### `run` +#### `run` ```python run(self: FastMCP, transport: Transport | None = None, show_banner: bool | None = None, **transport_kwargs: Any) -> None @@ -48,7 +48,7 @@ Run the FastMCP server. Note this is a synchronous function. FASTMCP_SHOW_SERVER_BANNER setting (default\: True). -#### `custom_route` +#### `custom_route` ```python custom_route(self: FastMCP, path: str, methods: list[str], name: str | None = None, include_in_schema: bool = True) -> Callable[[Callable[[Request], Awaitable[Response]]], Callable[[Request], Awaitable[Response]]] @@ -69,7 +69,7 @@ Starlette's reverse URL lookup feature) - `include_in_schema`: Whether to include in OpenAPI schema, defaults to True -#### `run_stdio_async` +#### `run_stdio_async` ```python run_stdio_async(self: FastMCP, show_banner: bool = True, log_level: str | None = None, stateless: bool = False) -> None @@ -83,7 +83,7 @@ Run the server using stdio transport. - `stateless`: Whether to run in stateless mode (no session initialization) -#### `run_http_async` +#### `run_http_async` ```python run_http_async(self: FastMCP, show_banner: bool = True, transport: Literal['http', 'streamable-http', 'sse'] = 'http', host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, stateless: bool | None = None) -> None @@ -104,7 +104,7 @@ Run the server using HTTP transport. - `stateless`: Alias for stateless_http for CLI consistency -#### `http_app` +#### `http_app` ```python http_app(self: FastMCP, path: str | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http', event_store: EventStore | None = None, retry_interval: int | None = None) -> StarletteWithLifespan diff --git a/docs/python-sdk/fastmcp-server-openapi-server.mdx b/docs/python-sdk/fastmcp-server-openapi-server.mdx index 4f751e090..374eac2ba 100644 --- a/docs/python-sdk/fastmcp-server-openapi-server.mdx +++ b/docs/python-sdk/fastmcp-server-openapi-server.mdx @@ -21,7 +21,7 @@ This class is deprecated. Use FastMCP with OpenAPIProvider instead: ## Classes -### `FastMCPOpenAPI` +### `FastMCPOpenAPI` FastMCP server implementation that creates components from an OpenAPI schema. diff --git a/docs/python-sdk/fastmcp-server-providers-aggregate.mdx b/docs/python-sdk/fastmcp-server-providers-aggregate.mdx index e0a8103da..ffc3d8dbb 100644 --- a/docs/python-sdk/fastmcp-server-providers-aggregate.mdx +++ b/docs/python-sdk/fastmcp-server-providers-aggregate.mdx @@ -64,7 +64,16 @@ FastMCPProvider to ensure middleware is invoked correctly. - Prompts become "namespace_promptname" -#### `get_tasks` +#### `get_app_tool` + +```python +get_app_tool(self, app_name: str, tool_name: str) -> Tool | None +``` + +Query all child providers for an app tool. + + +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -73,7 +82,7 @@ get_tasks(self) -> Sequence[FastMCPComponent] Get all task-eligible components from all providers. -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] diff --git a/docs/python-sdk/fastmcp-server-providers-base.mdx b/docs/python-sdk/fastmcp-server-providers-base.mdx index 09d02595b..715f52a0c 100644 --- a/docs/python-sdk/fastmcp-server-providers-base.mdx +++ b/docs/python-sdk/fastmcp-server-providers-base.mdx @@ -132,7 +132,22 @@ allowing session-level transforms to override provider-level disables. - The tool if found (may be marked disabled), None if not found. -#### `list_resources` +#### `get_app_tool` + +```python +get_app_tool(self, app_name: str, tool_name: str) -> Tool | None +``` + +Look up an app-visible tool by original name, bypassing transforms. + +Searches for a tool named ``tool_name`` tagged with the given app +name. Skips the transform chain entirely. + +**Returns:** +- The tool if found and tagged with the given app name, else None. + + +#### `list_resources` ```python list_resources(self) -> Sequence[Resource] @@ -143,7 +158,7 @@ List resources with all transforms applied. Components may be marked as disabled but are NOT filtered here. -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None @@ -162,7 +177,7 @@ Note: This method does NOT filter disabled components. The Server - The resource if found (may be marked disabled), None if not found. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] @@ -173,7 +188,7 @@ List resource templates with all transforms applied. Components may be marked as disabled but are NOT filtered here. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None @@ -192,7 +207,7 @@ Note: This method does NOT filter disabled components. The Server - The template if found (may be marked disabled), None if not found. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] @@ -203,7 +218,7 @@ List prompts with all transforms applied. Components may be marked as disabled but are NOT filtered here. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None @@ -222,7 +237,7 @@ Note: This method does NOT filter disabled components. The Server - The prompt if found (may be marked disabled), None if not found. -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -237,7 +252,7 @@ for components with task_config.mode != 'forbidden'. Used by the server during startup to register functions with Docket. -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] @@ -253,7 +268,7 @@ The lifespan scope matches the server's lifespan - code before yield runs at startup, code after yield runs at shutdown. -#### `enable` +#### `enable` ```python enable(self) -> Self @@ -281,7 +296,7 @@ VersionSpec(gte="v2")). Unversioned components will not match. - Self for method chaining. -#### `disable` +#### `disable` ```python disable(self) -> Self diff --git a/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx b/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx index 84d60da3d..ec5333e78 100644 --- a/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx +++ b/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx @@ -39,7 +39,7 @@ wrap(cls, server: Any, tool: Tool) -> FastMCPProviderTool Wrap a Tool to delegate execution to the server's middleware. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -51,13 +51,13 @@ This is called when the tool is used within a TransformedTool forwarding function or other contexts where task_meta is not available. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderResource` +### `FastMCPProviderResource` Resource that delegates reading to a wrapped server's read_resource(). @@ -68,7 +68,7 @@ When `read()` is called, this resource invokes the wrapped server's **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource @@ -77,13 +77,13 @@ wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource Wrap a Resource to delegate reading to the server's middleware. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderPrompt` +### `FastMCPProviderPrompt` Prompt that delegates rendering to a wrapped server's render_prompt(). @@ -94,7 +94,7 @@ When `render()` is called, this prompt invokes the wrapped server's **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt @@ -103,7 +103,7 @@ wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt Wrap a Prompt to delegate rendering to the server's middleware. -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> PromptResult @@ -115,13 +115,13 @@ This is called when the prompt is used within a transformed context or other contexts where task_meta is not available. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderResourceTemplate` +### `FastMCPProviderResourceTemplate` Resource template that creates FastMCPProviderResources. @@ -133,7 +133,7 @@ when read. **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, template: ResourceTemplate) -> FastMCPProviderResourceTemplate @@ -142,7 +142,7 @@ wrap(cls, server: Any, template: ResourceTemplate) -> FastMCPProviderResourceTem Wrap a ResourceTemplate to create FastMCPProviderResources. -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource @@ -155,7 +155,7 @@ We use `_original_uri_template` with `params` to construct the internal URI that the nested server understands. -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult @@ -167,7 +167,7 @@ Reads the resource via the wrapped server and returns the ResourceResult. This method is called by Docket during background task execution. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -176,7 +176,7 @@ register_with_docket(self, docket: Docket) -> None No-op: the child's actual template is registered via get_tasks(). -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution @@ -188,13 +188,13 @@ The child's FunctionResourceTemplate.fn is registered (via get_tasks), and it expects splatted **kwargs, so we splat params here. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProvider` +### `FastMCPProvider` Provider that wraps a FastMCP server. @@ -210,7 +210,16 @@ This ensures middleware runs when components are executed. **Methods:** -#### `get_tasks` +#### `get_app_tool` + +```python +get_app_tool(self, app_name: str, tool_name: str) -> Tool | None +``` + +Delegate to nested server's get_app_tool, wrapping for middleware. + + +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -224,7 +233,7 @@ server's transforms applied, then applies this provider's transforms for correct registration keys. -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] diff --git a/docs/python-sdk/fastmcp-server-providers-filesystem_discovery.mdx b/docs/python-sdk/fastmcp-server-providers-filesystem_discovery.mdx index 060398177..2a4595b89 100644 --- a/docs/python-sdk/fastmcp-server-providers-filesystem_discovery.mdx +++ b/docs/python-sdk/fastmcp-server-providers-filesystem_discovery.mdx @@ -16,7 +16,7 @@ This module provides functions to: ## Functions -### `discover_files` +### `discover_files` ```python discover_files(root: Path) -> list[Path] @@ -34,10 +34,10 @@ Excludes __init__.py files (they're for package structure, not components). - List of .py file paths, sorted for deterministic order. -### `import_module_from_file` +### `import_module_from_file` ```python -import_module_from_file(file_path: Path) -> ModuleType +import_module_from_file(file_path: Path, provider_root: Path | None = None) -> ModuleType ``` @@ -47,8 +47,13 @@ If the file is part of a package (directory has __init__.py), imports it as a proper package member (relative imports work). Otherwise, imports directly using spec_from_file_location. +sys.path is modified only for the duration of the import and restored +immediately after, so no permanent pollution occurs. + **Args:** - `file_path`: Path to the Python file. +- `provider_root`: The provider's root directory. Prevents package root +discovery from walking above this boundary into ancestor packages. **Returns:** - The imported module. @@ -57,7 +62,7 @@ imports directly using spec_from_file_location. - `ImportError`: If the module cannot be imported. -### `extract_components` +### `extract_components` ```python extract_components(module: ModuleType) -> list[FastMCPComponent] @@ -77,7 +82,7 @@ or functions decorated with @tool/@resource/@prompt that have __fastmcp__ metada - List of component objects (Tool, Resource, ResourceTemplate, Prompt). -### `discover_and_import` +### `discover_and_import` ```python discover_and_import(root: Path) -> DiscoveryResult @@ -97,7 +102,7 @@ This is the main entry point for filesystem-based discovery. ## Classes -### `DiscoveryResult` +### `DiscoveryResult` Result of filesystem discovery. diff --git a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx index efeae3661..802adb6e0 100644 --- a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx +++ b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx @@ -14,7 +14,7 @@ registration functionality to LocalProvider. ## Classes -### `ToolDecoratorMixin` +### `ToolDecoratorMixin` Mixin class providing tool decorator functionality for LocalProvider. @@ -26,7 +26,7 @@ This mixin contains all methods related to: **Methods:** -#### `add_tool` +#### `add_tool` ```python add_tool(self: LocalProvider, tool: Tool | Callable[..., Any]) -> Tool @@ -37,19 +37,19 @@ Add a tool to this provider's storage. Accepts either a Tool object or a decorated function with __fastmcp__ metadata. -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: F) -> F ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | None = None) -> Callable[[F], F] ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx index 94f0b7b6a..f16ecaaeb 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-components.mdx @@ -10,7 +10,7 @@ OpenAPI component classes: Tool, Resource, and ResourceTemplate. ## Classes -### `OpenAPITool` +### `OpenAPITool` Tool implementation for OpenAPI endpoints. @@ -18,7 +18,7 @@ Tool implementation for OpenAPI endpoints. **Methods:** -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -27,7 +27,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Execute the HTTP request using RequestDirector. -### `OpenAPIResource` +### `OpenAPIResource` Resource implementation for OpenAPI endpoints. @@ -35,7 +35,7 @@ Resource implementation for OpenAPI endpoints. **Methods:** -#### `read` +#### `read` ```python read(self) -> ResourceResult @@ -44,7 +44,7 @@ read(self) -> ResourceResult Fetch the resource data by making an HTTP request. -### `OpenAPIResourceTemplate` +### `OpenAPIResourceTemplate` Resource template implementation for OpenAPI endpoints. @@ -52,7 +52,7 @@ Resource template implementation for OpenAPI endpoints. **Methods:** -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource diff --git a/docs/python-sdk/fastmcp-server-providers-proxy.mdx b/docs/python-sdk/fastmcp-server-providers-proxy.mdx index 4c64d8566..d612a9f37 100644 --- a/docs/python-sdk/fastmcp-server-providers-proxy.mdx +++ b/docs/python-sdk/fastmcp-server-providers-proxy.mdx @@ -15,7 +15,7 @@ classes that forward execution to remote servers. ## Functions -### `default_proxy_roots_handler` +### `default_proxy_roots_handler` ```python default_proxy_roots_handler(context: RequestContext[ClientSession, LifespanContextT]) -> RootsList @@ -25,7 +25,7 @@ default_proxy_roots_handler(context: RequestContext[ClientSession, LifespanConte Forward list roots request from remote server to proxy's connected clients. -### `default_proxy_sampling_handler` +### `default_proxy_sampling_handler` ```python default_proxy_sampling_handler(messages: list[mcp.types.SamplingMessage], params: mcp.types.CreateMessageRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> mcp.types.CreateMessageResult @@ -35,7 +35,7 @@ default_proxy_sampling_handler(messages: list[mcp.types.SamplingMessage], params Forward sampling request from remote server to proxy's connected clients. -### `default_proxy_elicitation_handler` +### `default_proxy_elicitation_handler` ```python default_proxy_elicitation_handler(message: str, response_type: type, params: mcp.types.ElicitRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> ElicitResult @@ -45,7 +45,7 @@ default_proxy_elicitation_handler(message: str, response_type: type, params: mcp Forward elicitation request from remote server to proxy's connected clients. -### `default_proxy_log_handler` +### `default_proxy_log_handler` ```python default_proxy_log_handler(message: LogMessage) -> None @@ -55,7 +55,7 @@ default_proxy_log_handler(message: LogMessage) -> None Forward log notification from remote server to proxy's connected clients. -### `default_proxy_progress_handler` +### `default_proxy_progress_handler` ```python default_proxy_progress_handler(progress: float, total: float | None, message: str | None) -> None @@ -67,7 +67,7 @@ Forward progress notification from remote server to proxy's connected clients. ## Classes -### `ProxyTool` +### `ProxyTool` A Tool that represents and executes a tool on a remote server. @@ -75,7 +75,7 @@ A Tool that represents and executes a tool on a remote server. **Methods:** -#### `model_copy` +#### `model_copy` ```python model_copy(self, **kwargs: Any) -> ProxyTool @@ -84,7 +84,7 @@ model_copy(self, **kwargs: Any) -> ProxyTool Override to preserve _backend_name when name changes. -#### `from_mcp_tool` +#### `from_mcp_tool` ```python from_mcp_tool(cls, client_factory: ClientFactoryT, mcp_tool: mcp.types.Tool) -> ProxyTool @@ -93,7 +93,7 @@ from_mcp_tool(cls, client_factory: ClientFactoryT, mcp_tool: mcp.types.Tool) -> Factory method to create a ProxyTool from a raw MCP tool schema. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any], context: Context | None = None) -> ToolResult @@ -102,13 +102,13 @@ run(self, arguments: dict[str, Any], context: Context | None = None) -> ToolResu Executes the tool by making a call through the client. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `ProxyResource` +### `ProxyResource` A Resource that represents and reads a resource from a remote server. @@ -116,7 +116,7 @@ A Resource that represents and reads a resource from a remote server. **Methods:** -#### `model_copy` +#### `model_copy` ```python model_copy(self, **kwargs: Any) -> ProxyResource @@ -125,7 +125,7 @@ model_copy(self, **kwargs: Any) -> ProxyResource Override to preserve _backend_uri when uri changes. -#### `from_mcp_resource` +#### `from_mcp_resource` ```python from_mcp_resource(cls, client_factory: ClientFactoryT, mcp_resource: mcp.types.Resource) -> ProxyResource @@ -134,7 +134,7 @@ from_mcp_resource(cls, client_factory: ClientFactoryT, mcp_resource: mcp.types.R Factory method to create a ProxyResource from a raw MCP resource schema. -#### `read` +#### `read` ```python read(self) -> ResourceResult @@ -143,13 +143,13 @@ read(self) -> ResourceResult Read the resource content from the remote server. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `ProxyTemplate` +### `ProxyTemplate` A ResourceTemplate that represents and creates resources from a remote server template. @@ -157,7 +157,7 @@ A ResourceTemplate that represents and creates resources from a remote server te **Methods:** -#### `model_copy` +#### `model_copy` ```python model_copy(self, **kwargs: Any) -> ProxyTemplate @@ -166,7 +166,7 @@ model_copy(self, **kwargs: Any) -> ProxyTemplate Override to preserve _backend_uri_template when uri_template changes. -#### `from_mcp_template` +#### `from_mcp_template` ```python from_mcp_template(cls, client_factory: ClientFactoryT, mcp_template: mcp.types.ResourceTemplate) -> ProxyTemplate @@ -175,7 +175,7 @@ from_mcp_template(cls, client_factory: ClientFactoryT, mcp_template: mcp.types.R Factory method to create a ProxyTemplate from a raw MCP template schema. -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> ProxyResource @@ -184,13 +184,13 @@ create_resource(self, uri: str, params: dict[str, Any], context: Context | None Create a resource from the template by calling the remote server. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `ProxyPrompt` +### `ProxyPrompt` A Prompt that represents and renders a prompt from a remote server. @@ -198,7 +198,7 @@ A Prompt that represents and renders a prompt from a remote server. **Methods:** -#### `model_copy` +#### `model_copy` ```python model_copy(self, **kwargs: Any) -> ProxyPrompt @@ -207,7 +207,7 @@ model_copy(self, **kwargs: Any) -> ProxyPrompt Override to preserve _backend_name when name changes. -#### `from_mcp_prompt` +#### `from_mcp_prompt` ```python from_mcp_prompt(cls, client_factory: ClientFactoryT, mcp_prompt: mcp.types.Prompt) -> ProxyPrompt @@ -216,7 +216,7 @@ from_mcp_prompt(cls, client_factory: ClientFactoryT, mcp_prompt: mcp.types.Promp Factory method to create a ProxyPrompt from a raw MCP prompt schema. -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any]) -> PromptResult @@ -225,13 +225,13 @@ render(self, arguments: dict[str, Any]) -> PromptResult Render the prompt by making a call through the client. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `ProxyProvider` +### `ProxyProvider` Provider that proxies to a remote MCP server via a client factory. @@ -242,10 +242,20 @@ component instances that forward execution to the remote server. All components returned by this provider have task_config.mode="forbidden" because tasks cannot be executed through a proxy. +Component lists (tools, resources, templates, prompts) are cached so that +individual lookups (e.g. during ``call_tool``) can resolve from the cache +instead of opening a new backend connection. The cache stores the +backend's raw component metadata and is shared across all sessions; +per-session visibility and auth filtering are applied after cache lookup +by the server layer. The cache is refreshed whenever a ``list_*`` call +is made, and entries expire after ``cache_ttl`` seconds (default 300). +Set ``cache_ttl=0`` to disable caching. Disabling is recommended for +backends whose component lists change dynamically. + **Methods:** -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -258,7 +268,7 @@ server lifespan initialization, which would open the client before any context is set. All Proxy* components have task_config.mode="forbidden". -### `FastMCPProxy` +### `FastMCPProxy` A FastMCP server that acts as a proxy to a remote MCP-compliant server. @@ -267,7 +277,7 @@ This is a convenience wrapper that creates a FastMCP server with a ProxyProvider. For more control, use FastMCP with add_provider(ProxyProvider(...)). -### `ProxyClient` +### `ProxyClient` A proxy client that forwards advanced interactions between a remote MCP server and the proxy's connected clients. @@ -275,7 +285,7 @@ A proxy client that forwards advanced interactions between a remote MCP server a Supports forwarding roots, sampling, elicitation, logging, and progress. -### `StatefulProxyClient` +### `StatefulProxyClient` A proxy client that provides a stateful client factory for the proxy server. @@ -296,7 +306,7 @@ it to detect (and correct) staleness. **Methods:** -#### `clear` +#### `clear` ```python clear(self) @@ -305,7 +315,7 @@ clear(self) Clear all cached clients and force disconnect them. -#### `new_stateful` +#### `new_stateful` ```python new_stateful(self) -> Client[ClientTransportT] diff --git a/docs/python-sdk/fastmcp-server-sampling-run.mdx b/docs/python-sdk/fastmcp-server-sampling-run.mdx index c09ad42d5..ea6d46104 100644 --- a/docs/python-sdk/fastmcp-server-sampling-run.mdx +++ b/docs/python-sdk/fastmcp-server-sampling-run.mdx @@ -44,7 +44,7 @@ sampling_handler is set via determine_handler_mode(). The checks below are safeguards against internal misuse. -### `execute_tools` +### `execute_tools` ```python execute_tools(tool_calls: list[ToolUseContent], tool_map: dict[str, SamplingTool], mask_error_details: bool = False, tool_concurrency: int | None = None) -> list[ToolResultContent] @@ -71,7 +71,7 @@ regardless of this setting. - List of tool result content blocks in the same order as tool_calls. -### `prepare_messages` +### `prepare_messages` ```python prepare_messages(messages: str | Sequence[str | SamplingMessage]) -> list[SamplingMessage] @@ -81,7 +81,7 @@ prepare_messages(messages: str | Sequence[str | SamplingMessage]) -> list[Sampli Convert various message formats to a list of SamplingMessage objects. -### `prepare_tools` +### `prepare_tools` ```python prepare_tools(tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] | None) -> list[SamplingTool] | None @@ -102,7 +102,7 @@ TransformedTool, or plain callable functions. - List of SamplingTool instances, or None if tools is None. -### `extract_tool_calls` +### `extract_tool_calls` ```python extract_tool_calls(response: CreateMessageResult | CreateMessageResultWithTools) -> list[ToolUseContent] @@ -112,7 +112,7 @@ extract_tool_calls(response: CreateMessageResult | CreateMessageResultWithTools) Extract tool calls from a response. -### `create_final_response_tool` +### `create_final_response_tool` ```python create_final_response_tool(result_type: type) -> SamplingTool @@ -125,7 +125,7 @@ This tool is used to capture structured responses from the LLM. The tool's schema is derived from the result_type. -### `sample_step_impl` +### `sample_step_impl` ```python sample_step_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SampleStep @@ -138,7 +138,7 @@ Make a single LLM sampling call. This is a stateless function that makes exactly one LLM call and optionally executes any requested tools. -### `sample_impl` +### `sample_impl` ```python sample_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] diff --git a/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx b/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx index cac91d36e..2a3590003 100644 --- a/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx +++ b/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx @@ -10,7 +10,7 @@ SamplingTool for use during LLM sampling requests. ## Classes -### `SamplingTool` +### `SamplingTool` A tool that can be used during LLM sampling. @@ -37,7 +37,7 @@ Create a SamplingTool explicitly when you need custom name/description: **Methods:** -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any] | None = None) -> Any @@ -52,7 +52,7 @@ Execute the tool with the given arguments. - The result of executing the tool function. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> SamplingTool @@ -79,7 +79,7 @@ concurrently. Defaults to False. - `ValueError`: If the function is a lambda without a name override. -#### `from_callable_tool` +#### `from_callable_tool` ```python from_callable_tool(cls, tool: FunctionTool | TransformedTool) -> SamplingTool diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index dbd0ad15a..14fcf04ca 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers. ## Functions -### `default_lifespan` +### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `create_proxy` +### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -54,53 +54,53 @@ use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.pr ## Classes -### `StateValue` +### `StateValue` Wrapper for stored context state values. -### `FastMCP` +### `FastMCP` **Methods:** -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `instructions` +#### `instructions` ```python instructions(self, value: str | None) -> None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `website_url` +#### `website_url` ```python website_url(self) -> str | None ``` -#### `icons` +#### `icons` ```python icons(self) -> list[mcp.types.Icon] ``` -#### `local_provider` +#### `local_provider` ```python local_provider(self) -> LocalProvider @@ -115,13 +115,13 @@ Use this to remove components: mcp.local_provider.remove_prompt("my_prompt") -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `add_provider` +#### `add_provider` ```python add_provider(self, provider: Provider) -> None @@ -141,7 +141,7 @@ always take precedence over providers. - Prompts become "namespace_promptname" -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -153,7 +153,7 @@ Overrides AggregateProvider.get_tasks() to apply server-level transforms after aggregation. AggregateProvider handles provider-level namespacing. -#### `add_transform` +#### `add_transform` ```python add_transform(self, transform: Transform) -> None @@ -168,7 +168,7 @@ They transform tools, resources, and prompts from ALL providers. - `transform`: The transform to add. -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -180,7 +180,7 @@ Add a tool transformation. Use ``add_transform(ToolTransform({...}))`` instead. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, _tool_name: str) -> None @@ -192,7 +192,7 @@ Remove a tool transformation. Tool transformations are now immutable. Use enable/disable controls instead. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> Sequence[Tool] @@ -205,7 +205,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None @@ -217,6 +217,9 @@ Overrides Provider.get_tool() to add visibility filtering after all transforms (including session-level) have been applied. This ensures session transforms can override provider-level disables. +When the highest version is disabled and no explicit version was +requested, falls back to the next-highest enabled version. + **Args:** - `name`: The tool name. - `version`: Version filter (None returns highest version). @@ -225,7 +228,7 @@ session transforms can override provider-level disables. - The tool if found and enabled, None otherwise. -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> Sequence[Resource] @@ -238,7 +241,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None @@ -249,6 +252,9 @@ Get a resource by URI, filtering disabled resources. Overrides Provider.get_resource() to add visibility filtering after all transforms (including session-level) have been applied. +When the highest version is disabled and no explicit version was +requested, falls back to the next-highest enabled version. + **Args:** - `uri`: The resource URI. - `version`: Version filter (None returns highest version). @@ -257,7 +263,7 @@ transforms (including session-level) have been applied. - The resource if found and enabled, None otherwise. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] @@ -270,7 +276,7 @@ auth filtering, and middleware execution. Returns all versions (no deduplication Protocol handlers deduplicate for MCP wire format. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None @@ -281,6 +287,9 @@ Get a resource template by URI, filtering disabled templates. Overrides Provider.get_resource_template() to add visibility filtering after all transforms (including session-level) have been applied. +When the highest version is disabled and no explicit version was +requested, falls back to the next-highest enabled version. + **Args:** - `uri`: The template URI. - `version`: Version filter (None returns highest version). @@ -289,7 +298,7 @@ all transforms (including session-level) have been applied. - The template if found and enabled, None otherwise. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] @@ -302,7 +311,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None @@ -313,6 +322,9 @@ Get a prompt by name, filtering disabled prompts. Overrides Provider.get_prompt() to add visibility filtering after all transforms (including session-level) have been applied. +When the highest version is disabled and no explicit version was +requested, falls back to the next-highest enabled version. + **Args:** - `name`: The prompt name. - `version`: Version filter (None returns highest version). @@ -321,19 +333,19 @@ transforms (including session-level) have been applied. - The prompt if found and enabled, None otherwise. -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult | mcp.types.CreateTaskResult @@ -363,19 +375,19 @@ return ToolResult. - `ValidationError`: If arguments fail validation -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> mcp.types.CreateTaskResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult | mcp.types.CreateTaskResult @@ -404,19 +416,19 @@ return ResourceResult. - `ResourceError`: If resource read fails -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult | mcp.types.CreateTaskResult @@ -446,7 +458,7 @@ return PromptResult. - `PromptError`: If prompt rendering fails -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool @@ -464,7 +476,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str, version: str | None = None) -> None @@ -483,19 +495,19 @@ Remove tool(s) from the server. - `NotFoundError`: If no matching tool is found. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: F) -> F ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] @@ -551,7 +563,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate @@ -566,7 +578,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -581,7 +593,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[F], F] @@ -640,7 +652,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt @@ -655,19 +667,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: F) -> F ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] @@ -744,7 +756,7 @@ Decorator to register a prompt. ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None) -> None @@ -791,7 +803,7 @@ mounted server. - `prefix`: Deprecated. Use namespace instead. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None) -> None @@ -832,7 +844,7 @@ templates, and prompts are imported with their original names. objects are imported with their original names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any) -> Self @@ -861,7 +873,7 @@ response structure while still returning structured JSON. - A FastMCP server with an OpenAPIProvider attached. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self @@ -885,7 +897,7 @@ Use this to configure timeout and other client settings. - A FastMCP server with an OpenAPIProvider attached. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -903,7 +915,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-server-tasks-config.mdx b/docs/python-sdk/fastmcp-server-tasks-config.mdx index 0dc2bf4ba..a014e1ac4 100644 --- a/docs/python-sdk/fastmcp-server-tasks-config.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-config.mdx @@ -14,7 +14,7 @@ handle task-augmented execution as specified in SEP-1686. ## Classes -### `TaskMeta` +### `TaskMeta` Metadata for task-augmented execution requests. @@ -27,7 +27,7 @@ the operation should be submitted as a background task. - `fn_key`: Docket routing key. Auto-derived from component name if None. -### `TaskConfig` +### `TaskConfig` Configuration for MCP background task execution (SEP-1686). @@ -44,7 +44,7 @@ Controls how a component handles task-augmented requests: **Methods:** -#### `from_bool` +#### `from_bool` ```python from_bool(cls, value: bool) -> TaskConfig @@ -59,7 +59,7 @@ Convert boolean task flag to TaskConfig. - TaskConfig with appropriate mode. -#### `supports_tasks` +#### `supports_tasks` ```python supports_tasks(self) -> bool @@ -71,7 +71,7 @@ Check if this component supports task execution. - True if mode is "optional" or "required", False if "forbidden". -#### `validate_function` +#### `validate_function` ```python validate_function(self, fn: Callable[..., Any], name: str) -> None diff --git a/docs/python-sdk/fastmcp-server-tasks-handlers.mdx b/docs/python-sdk/fastmcp-server-tasks-handlers.mdx index 31f228f14..e7b1ed35e 100644 --- a/docs/python-sdk/fastmcp-server-tasks-handlers.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-handlers.mdx @@ -13,7 +13,7 @@ Handles queuing tool/prompt/resource executions to Docket as background tasks. ## Functions -### `submit_to_docket` +### `submit_to_docket` ```python submit_to_docket(task_type: Literal['tool', 'resource', 'template', 'prompt'], key: str, component: Tool | Resource | ResourceTemplate | Prompt, arguments: dict[str, Any] | None = None, task_meta: TaskMeta | None = None) -> mcp.types.CreateTaskResult diff --git a/docs/python-sdk/fastmcp-server-tasks-requests.mdx b/docs/python-sdk/fastmcp-server-tasks-requests.mdx index 5cde802fc..a8b31a13d 100644 --- a/docs/python-sdk/fastmcp-server-tasks-requests.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-requests.mdx @@ -52,7 +52,7 @@ Converts raw task return values to MCP types based on task type. - MCP result (CallToolResult, GetPromptResult, or ReadResourceResult) -### `tasks_list_handler` +### `tasks_list_handler` ```python tasks_list_handler(server: FastMCP, params: dict[str, Any]) -> ListTasksResult @@ -71,7 +71,7 @@ Note: With client-side tracking, this returns minimal info. - Response with tasks list and pagination -### `tasks_cancel_handler` +### `tasks_cancel_handler` ```python tasks_cancel_handler(server: FastMCP, params: dict[str, Any]) -> CancelTaskResult diff --git a/docs/python-sdk/fastmcp-server-transforms-catalog.mdx b/docs/python-sdk/fastmcp-server-transforms-catalog.mdx index 1dd2cfe4a..5728d65b1 100644 --- a/docs/python-sdk/fastmcp-server-transforms-catalog.mdx +++ b/docs/python-sdk/fastmcp-server-transforms-catalog.mdx @@ -52,7 +52,7 @@ Usage:: ## Classes -### `CatalogTransform` +### `CatalogTransform` Transform that needs access to the real component catalog. @@ -70,31 +70,31 @@ by temporarily setting a bypass flag so that this transform's **Methods:** -#### `list_tools` +#### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` -#### `list_resources` +#### `list_resources` ```python list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource] ``` -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate] ``` -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt] ``` -#### `transform_tools` +#### `transform_tools` ```python transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] @@ -110,7 +110,7 @@ to handle re-entrant bypass when ``get_tool_catalog()`` reads the real catalog. -#### `transform_resources` +#### `transform_resources` ```python transform_resources(self, resources: Sequence[Resource]) -> Sequence[Resource] @@ -126,7 +126,7 @@ to handle re-entrant bypass when ``get_resource_catalog()`` reads the real catalog. -#### `transform_resource_templates` +#### `transform_resource_templates` ```python transform_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate] @@ -142,7 +142,7 @@ uses it to handle re-entrant bypass when ``get_resource_template_catalog()`` reads the real catalog. -#### `transform_prompts` +#### `transform_prompts` ```python transform_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt] @@ -158,7 +158,7 @@ to handle re-entrant bypass when ``get_prompt_catalog()`` reads the real catalog. -#### `get_tool_catalog` +#### `get_tool_catalog` ```python get_tool_catalog(self, ctx: Context) -> Sequence[Tool] @@ -166,6 +166,10 @@ get_tool_catalog(self, ctx: Context) -> Sequence[Tool] Fetch the real tool catalog, bypassing this transform. +The result is deduplicated by name so that only the highest version +of each tool is returned — matching what protocol handlers expose +on the wire. + **Args:** - `ctx`: The current request context. - `run_middleware`: Whether to run middleware on the inner call. @@ -173,7 +177,7 @@ Defaults to True because this is typically called from a tool handler where list_tools middleware has not yet run. -#### `get_resource_catalog` +#### `get_resource_catalog` ```python get_resource_catalog(self, ctx: Context) -> Sequence[Resource] @@ -188,7 +192,7 @@ Defaults to True because this is typically called from a tool handler where list_resources middleware has not yet run. -#### `get_prompt_catalog` +#### `get_prompt_catalog` ```python get_prompt_catalog(self, ctx: Context) -> Sequence[Prompt] @@ -203,7 +207,7 @@ Defaults to True because this is typically called from a tool handler where list_prompts middleware has not yet run. -#### `get_resource_template_catalog` +#### `get_resource_template_catalog` ```python get_resource_template_catalog(self, ctx: Context) -> Sequence[ResourceTemplate] diff --git a/docs/python-sdk/fastmcp-server-transforms-prompts_as_tools.mdx b/docs/python-sdk/fastmcp-server-transforms-prompts_as_tools.mdx index f1656c263..dc3b659d0 100644 --- a/docs/python-sdk/fastmcp-server-transforms-prompts_as_tools.mdx +++ b/docs/python-sdk/fastmcp-server-transforms-prompts_as_tools.mdx @@ -11,6 +11,10 @@ Transform that exposes prompts as tools. This transform generates tools for listing and getting prompts, enabling clients that only support tools to access prompt functionality. +The generated tools route through `ctx.fastmcp` at runtime, so all server +middleware (auth, visibility, rate limiting, etc.) applies to prompt +operations exactly as it would for direct `prompts/get` calls. + Example: ```python from fastmcp import FastMCP @@ -24,23 +28,26 @@ Example: ## Classes -### `PromptsAsTools` +### `PromptsAsTools` Transform that adds tools for listing and getting prompts. Generates two tools: -- `list_prompts`: Lists all prompts from the provider +- `list_prompts`: Lists all prompts - `get_prompt`: Gets a specific prompt with optional arguments -The transform captures a provider reference at construction and queries it -for prompts when the generated tools are called. When used with FastMCP, -the provider's auth and visibility filtering is automatically applied. +The generated tools route through the server at runtime, so auth, +middleware, and visibility apply automatically. + +This transform should be applied to a FastMCP server instance, not +a raw Provider, because the generated tools need the server's +middleware chain for auth and visibility filtering. **Methods:** -#### `list_tools` +#### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] @@ -49,7 +56,7 @@ list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] Add prompt tools to the tool list. -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None diff --git a/docs/python-sdk/fastmcp-server-transforms-resources_as_tools.mdx b/docs/python-sdk/fastmcp-server-transforms-resources_as_tools.mdx index 3b46e875c..50f0a0c95 100644 --- a/docs/python-sdk/fastmcp-server-transforms-resources_as_tools.mdx +++ b/docs/python-sdk/fastmcp-server-transforms-resources_as_tools.mdx @@ -11,6 +11,10 @@ Transform that exposes resources as tools. This transform generates tools for listing and reading resources, enabling clients that only support tools to access resource functionality. +The generated tools route through `ctx.fastmcp` at runtime, so all server +middleware (auth, visibility, rate limiting, etc.) applies to resource +operations exactly as it would for direct `resources/read` calls. + Example: ```python from fastmcp import FastMCP @@ -24,23 +28,26 @@ Example: ## Classes -### `ResourcesAsTools` +### `ResourcesAsTools` Transform that adds tools for listing and reading resources. Generates two tools: -- `list_resources`: Lists all resources and templates from the provider +- `list_resources`: Lists all resources and templates - `read_resource`: Reads a resource by URI -The transform captures a provider reference at construction and queries it -for resources when the generated tools are called. When used with FastMCP, -the provider's auth and visibility filtering is automatically applied. +The generated tools route through the server at runtime, so auth, +middleware, and visibility apply automatically. + +This transform should be applied to a FastMCP server instance, not +a raw Provider, because the generated tools need the server's +middleware chain for auth and visibility filtering. **Methods:** -#### `list_tools` +#### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] @@ -49,7 +56,7 @@ list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] Add resource tools to the tool list. -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None diff --git a/docs/python-sdk/fastmcp-settings.mdx b/docs/python-sdk/fastmcp-settings.mdx index eeb1156f8..f9b0cf26a 100644 --- a/docs/python-sdk/fastmcp-settings.mdx +++ b/docs/python-sdk/fastmcp-settings.mdx @@ -7,13 +7,13 @@ sidebarTitle: settings ## Classes -### `DocketSettings` +### `DocketSettings` Docket worker configuration. -### `Settings` +### `Settings` FastMCP settings. @@ -21,7 +21,7 @@ FastMCP settings. **Methods:** -#### `get_setting` +#### `get_setting` ```python get_setting(self, attr: str) -> Any @@ -31,7 +31,7 @@ Get a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `set_setting` +#### `set_setting` ```python set_setting(self, attr: str, value: Any) -> None @@ -41,7 +41,7 @@ Set a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `normalize_log_level` +#### `normalize_log_level` ```python normalize_log_level(cls, v) diff --git a/docs/python-sdk/fastmcp-tools-tool.mdx b/docs/python-sdk/fastmcp-tools-base.mdx similarity index 80% rename from docs/python-sdk/fastmcp-tools-tool.mdx rename to docs/python-sdk/fastmcp-tools-base.mdx index 0394bf4a5..4f8d362ea 100644 --- a/docs/python-sdk/fastmcp-tools-tool.mdx +++ b/docs/python-sdk/fastmcp-tools-base.mdx @@ -1,13 +1,13 @@ --- -title: tool -sidebarTitle: tool +title: base +sidebarTitle: base --- -# `fastmcp.tools.tool` +# `fastmcp.tools.base` ## Functions -### `default_serializer` +### `default_serializer` ```python default_serializer(data: Any) -> str @@ -15,17 +15,17 @@ default_serializer(data: Any) -> str ## Classes -### `ToolResult` +### `ToolResult` **Methods:** -#### `to_mcp_result` +#### `to_mcp_result` ```python to_mcp_result(self) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult ``` -### `Tool` +### `Tool` Internal tool registration info. @@ -33,7 +33,7 @@ Internal tool registration info. **Methods:** -#### `to_mcp_tool` +#### `to_mcp_tool` ```python to_mcp_tool(self, **overrides: Any) -> MCPTool @@ -42,7 +42,7 @@ to_mcp_tool(self, **overrides: Any) -> MCPTool Convert the FastMCP tool to an MCP tool. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionTool @@ -51,7 +51,7 @@ from_function(cls, fn: Callable[..., Any]) -> FunctionTool Create a Tool from a function. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -66,7 +66,7 @@ implemented by subclasses. (list of ContentBlocks, dict of structured output). -#### `convert_result` +#### `convert_result` ```python convert_result(self, raw_value: Any) -> ToolResult @@ -78,7 +78,7 @@ Handles ToolResult passthrough and converts raw values using the tool's attributes (serializer, output_schema) for proper conversion. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -87,7 +87,7 @@ register_with_docket(self, docket: Docket) -> None Register this tool with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution @@ -103,13 +103,13 @@ Schedule this tool for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `from_tool` +#### `from_tool` ```python from_tool(cls, tool: Tool | Callable[..., Any]) -> TransformedTool ``` -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-tools-function_parsing.mdx b/docs/python-sdk/fastmcp-tools-function_parsing.mdx index f9cd7f28e..6264ef5d3 100644 --- a/docs/python-sdk/fastmcp-tools-function_parsing.mdx +++ b/docs/python-sdk/fastmcp-tools-function_parsing.mdx @@ -10,11 +10,11 @@ Function introspection and schema generation for FastMCP tools. ## Classes -### `ParsedFunction` +### `ParsedFunction` **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], exclude_args: list[str] | None = None, validate: bool = True, wrap_non_object_output_schema: bool = True) -> ParsedFunction diff --git a/docs/python-sdk/fastmcp-tools-function_tool.mdx b/docs/python-sdk/fastmcp-tools-function_tool.mdx index d25c6ecf8..d18d659f8 100644 --- a/docs/python-sdk/fastmcp-tools-function_tool.mdx +++ b/docs/python-sdk/fastmcp-tools-function_tool.mdx @@ -10,7 +10,7 @@ Standalone @tool decorator for FastMCP. ## Functions -### `tool` +### `tool` ```python tool(name_or_fn: str | Callable[..., Any] | None = None) -> Any @@ -25,34 +25,23 @@ using mcp.add_tool(). ## Classes -### `DecoratedTool` +### `DecoratedTool` Protocol for functions decorated with @tool. -### `ToolMeta` +### `ToolMeta` Metadata attached to functions by the @tool decorator. -### `FunctionTool` +### `FunctionTool` **Methods:** -#### `to_mcp_tool` - -```python -to_mcp_tool(self, **overrides: Any) -> mcp.types.Tool -``` - -Convert the FastMCP tool to an MCP tool. - -Extends the base implementation to add task execution mode if enabled. - - -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionTool @@ -68,7 +57,7 @@ individual parameters must not be passed. Cannot be used together with metadata parameter. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -77,7 +66,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Run the tool with arguments. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -86,10 +75,12 @@ register_with_docket(self, docket: Docket) -> None Register this tool with docket for background execution. FunctionTool registers the underlying function, which has the user's -Depends parameters for docket to resolve. +Depends parameters for docket to resolve. The function is wrapped to +eagerly restore HTTP headers from Redis so that get_http_request() +works even without explicit dependency injection. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution diff --git a/docs/python-sdk/fastmcp-tools-tool_transform.mdx b/docs/python-sdk/fastmcp-tools-tool_transform.mdx index 84a1b66cf..24f6270aa 100644 --- a/docs/python-sdk/fastmcp-tools-tool_transform.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_transform.mdx @@ -7,7 +7,7 @@ sidebarTitle: tool_transform ## Functions -### `forward` +### `forward` ```python forward(**kwargs: Any) -> ToolResult @@ -36,7 +36,7 @@ tool has args `a` and `b`, and an `transform_args` was provided that maps `x` to - `TypeError`: If provided arguments don't match the transformed schema. -### `forward_raw` +### `forward_raw` ```python forward_raw(**kwargs: Any) -> ToolResult @@ -62,7 +62,7 @@ y=2)` will call the parent tool with `x=1` and `y=2`. - `RuntimeError`: If called outside a transformed tool context. -### `apply_transformations_to_tools` +### `apply_transformations_to_tools` ```python apply_transformations_to_tools(tools: dict[str, Tool], transformations: dict[str, ToolTransformConfig]) -> dict[str, Tool] @@ -78,7 +78,7 @@ but transformations are keyed by tool name (e.g., "my_tool"). ## Classes -### `ArgTransform` +### `ArgTransform` Configuration for transforming a parent tool's argument. @@ -150,7 +150,7 @@ ArgTransform(name="new_name", description="New desc", default=None, type=int) ``` -### `ArgTransformConfig` +### `ArgTransformConfig` A model for requesting a single argument transform. @@ -158,7 +158,7 @@ A model for requesting a single argument transform. **Methods:** -#### `to_arg_transform` +#### `to_arg_transform` ```python to_arg_transform(self) -> ArgTransform @@ -167,7 +167,7 @@ to_arg_transform(self) -> ArgTransform Convert the argument transform to a FastMCP argument transform. -### `TransformedTool` +### `TransformedTool` A tool that is transformed from another tool. @@ -191,7 +191,7 @@ validation when forward() is called from custom functions. **Methods:** -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -210,7 +210,7 @@ functions. - ToolResult object containing content and optional structured output. -#### `from_tool` +#### `from_tool` ```python from_tool(cls, tool: Tool | Callable[..., Any], name: str | None = None, version: str | NotSetT | None = NotSet, title: str | NotSetT | None = NotSet, description: str | NotSetT | None = NotSet, tags: set[str] | None = None, transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, annotations: ToolAnnotations | NotSetT | None = NotSet, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: Callable[[Any], str] | NotSetT | None = NotSet, meta: dict[str, Any] | NotSetT | None = NotSet) -> TransformedTool @@ -293,7 +293,7 @@ async def custom_output(**kwargs) -> ToolResult: ``` -### `ToolTransformConfig` +### `ToolTransformConfig` Provides a way to transform a tool. @@ -301,7 +301,7 @@ Provides a way to transform a tool. **Methods:** -#### `apply` +#### `apply` ```python apply(self, tool: Tool) -> TransformedTool diff --git a/docs/python-sdk/fastmcp-types.mdx b/docs/python-sdk/fastmcp-types.mdx new file mode 100644 index 000000000..7aa854d47 --- /dev/null +++ b/docs/python-sdk/fastmcp-types.mdx @@ -0,0 +1,25 @@ +--- +title: types +sidebarTitle: types +--- + +# `fastmcp.types` + + +Reusable type annotations for FastMCP tool parameters. + +These types can be used in tool function signatures to influence how +parameters are presented in UIs (e.g. ``fastmcp dev apps``) and +serialized in JSON Schema. + +Example:: + + from fastmcp import FastMCP + from fastmcp.types import Textarea + + mcp = FastMCP("demo") + + @mcp.tool() + def run_query(sql: Textarea) -> str: + ... + diff --git a/docs/python-sdk/fastmcp-utilities-async_utils.mdx b/docs/python-sdk/fastmcp-utilities-async_utils.mdx index cfd0cd7ab..75c6edd47 100644 --- a/docs/python-sdk/fastmcp-utilities-async_utils.mdx +++ b/docs/python-sdk/fastmcp-utilities-async_utils.mdx @@ -10,7 +10,21 @@ Async utilities for FastMCP. ## Functions -### `call_sync_fn_in_threadpool` +### `is_coroutine_function` + +```python +is_coroutine_function(fn: Any) -> bool +``` + + +Check if a callable is a coroutine function, unwrapping functools.partial. + +``inspect.iscoroutinefunction`` returns ``False`` for +``functools.partial`` objects wrapping an async function on Python < 3.12. +This helper unwraps any layers of ``partial`` before checking. + + +### `call_sync_fn_in_threadpool` ```python call_sync_fn_in_threadpool(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any @@ -23,7 +37,7 @@ Uses anyio.to_thread.run_sync which properly propagates contextvars, making this safe for functions that depend on context (like dependency injection). -### `gather` +### `gather` ```python gather(*awaitables: Awaitable[T]) -> list[T] | list[T | BaseException] diff --git a/docs/python-sdk/fastmcp-utilities-components.mdx b/docs/python-sdk/fastmcp-utilities-components.mdx index eba2517c2..e45129a28 100644 --- a/docs/python-sdk/fastmcp-utilities-components.mdx +++ b/docs/python-sdk/fastmcp-utilities-components.mdx @@ -24,7 +24,7 @@ namespace for compatibility with older FastMCP servers. ### `FastMCPMeta` -### `FastMCPComponent` +### `FastMCPComponent` Base class for FastMCP tools, prompts, resources, and resource templates. @@ -32,7 +32,7 @@ Base class for FastMCP tools, prompts, resources, and resource templates. **Methods:** -#### `make_key` +#### `make_key` ```python make_key(cls, identifier: str) -> str @@ -47,7 +47,7 @@ Construct the lookup key for this component type. - A prefixed key like "tool:name" or "resource:uri" -#### `key` +#### `key` ```python key(self) -> str @@ -65,7 +65,7 @@ Subclasses should override this to use their specific identifier. Base implementation uses name. -#### `get_meta` +#### `get_meta` ```python get_meta(self) -> dict[str, Any] @@ -80,7 +80,7 @@ Returns a dict that always includes a `fastmcp` key containing: Internal keys (prefixed with `_`) are stripped from the fastmcp namespace. -#### `enable` +#### `enable` ```python enable(self) -> None @@ -89,7 +89,7 @@ enable(self) -> None Removed in 3.0. Use server.enable(keys=[...]) instead. -#### `disable` +#### `disable` ```python disable(self) -> None @@ -98,7 +98,7 @@ disable(self) -> None Removed in 3.0. Use server.disable(keys=[...]) instead. -#### `copy` +#### `copy` ```python copy(self) -> Self @@ -107,7 +107,7 @@ copy(self) -> Self Create a copy of the component. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -119,7 +119,7 @@ No-ops if task_config.mode is "forbidden". Subclasses override to register their callable (self.run, self.read, self.render, or self.fn). -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, *args: Any, **kwargs: Any) -> Execution @@ -136,7 +136,7 @@ Subclasses override this to handle their specific calling conventions: The **kwargs are passed through to docket.add() (e.g., key=task_key). -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx index e08643cb9..86ca44f9e 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 -### `dereference_refs` +### `dereference_refs` ```python dereference_refs(schema: dict[str, Any]) -> dict[str, Any] @@ -27,6 +27,11 @@ For self-referencing/circular schemas where full dereferencing is not possible, this function falls back to resolving only the root-level $ref while preserving $defs for nested references. +Only local ``$ref`` values (those starting with ``#``) are resolved. +Remote URIs (``http://``, ``file://``, etc.) are stripped before +resolution to prevent SSRF / local-file-inclusion attacks when proxying +schemas from untrusted servers. + **Args:** - `schema`: JSON schema dict that may contain $ref references @@ -35,7 +40,7 @@ $defs for nested references. - when no longer needed -### `resolve_root_ref` +### `resolve_root_ref` ```python resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any] @@ -57,7 +62,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-mcp_server_config-v1-sources-filesystem.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx index 3d791f9cd..15bd86966 100644 --- a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx @@ -7,7 +7,7 @@ sidebarTitle: filesystem ## Classes -### `FileSystemSource` +### `FileSystemSource` Source for local Python files. @@ -15,7 +15,7 @@ Source for local Python files. **Methods:** -#### `parse_path_with_object` +#### `parse_path_with_object` ```python parse_path_with_object(cls, v: str) -> str @@ -27,7 +27,7 @@ This validator runs before the model is created, allowing us to handle the "file.py:object" syntax at the model boundary. -#### `load_server` +#### `load_server` ```python load_server(self) -> Any diff --git a/docs/python-sdk/fastmcp-utilities-mime.mdx b/docs/python-sdk/fastmcp-utilities-mime.mdx new file mode 100644 index 000000000..b823d447b --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-mime.mdx @@ -0,0 +1,35 @@ +--- +title: mime +sidebarTitle: mime +--- + +# `fastmcp.utilities.mime` + + +MIME type constants and helpers for MCP Apps UI resources. + +This module has no dependencies on the server or resource packages, +so it can be safely imported from anywhere. + + +## Functions + +### `resolve_ui_mime_type` + +```python +resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None +``` + + +Return the appropriate MIME type for a resource URI. + +For ``ui://`` scheme resources, defaults to ``UI_MIME_TYPE`` when no +explicit MIME type is provided. + +**Args:** +- `uri`: The resource URI string +- `explicit_mime_type`: The MIME type explicitly provided by the user + +**Returns:** +- The resolved MIME type (explicit value, UI default, or None) + diff --git a/docs/python-sdk/fastmcp-utilities-openapi-director.mdx b/docs/python-sdk/fastmcp-utilities-openapi-director.mdx index 0d61900c6..1d0687637 100644 --- a/docs/python-sdk/fastmcp-utilities-openapi-director.mdx +++ b/docs/python-sdk/fastmcp-utilities-openapi-director.mdx @@ -10,7 +10,7 @@ Request director using openapi-core for stateless HTTP request building. ## Classes -### `RequestDirector` +### `RequestDirector` Builds httpx.Request objects from HTTPRoute and arguments using openapi-core. @@ -18,7 +18,7 @@ Builds httpx.Request objects from HTTPRoute and arguments using openapi-core. **Methods:** -#### `build` +#### `build` ```python build(self, route: HTTPRoute, flat_args: dict[str, Any], base_url: str = 'http://localhost') -> httpx.Request diff --git a/docs/python-sdk/fastmcp-utilities-skills.mdx b/docs/python-sdk/fastmcp-utilities-skills.mdx index ccb0c83b8..807d07782 100644 --- a/docs/python-sdk/fastmcp-utilities-skills.mdx +++ b/docs/python-sdk/fastmcp-utilities-skills.mdx @@ -75,7 +75,7 @@ Creates a subdirectory named after the skill containing all files. - `FileExistsError`: If skill directory exists and overwrite=False -### `sync_skills` +### `sync_skills` ```python sync_skills(client: Client, target_dir: str | Path) -> list[Path] diff --git a/docs/python-sdk/fastmcp-utilities-token_cache.mdx b/docs/python-sdk/fastmcp-utilities-token_cache.mdx new file mode 100644 index 000000000..af0a890f8 --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-token_cache.mdx @@ -0,0 +1,87 @@ +--- +title: token_cache +sidebarTitle: token_cache +--- + +# `fastmcp.utilities.token_cache` + + +In-memory cache for token verification results. + +Provides a generic TTL-based cache for ``AccessToken`` objects, designed to +reduce repeated network calls during opaque-token verification. Only +*successful* verifications should be cached; errors and failures must be +retried on every request. + +Example: + ```python + from fastmcp.utilities.token_cache import TokenCache + + cache = TokenCache(ttl_seconds=300, max_size=10000) + + # On cache miss, call the upstream verifier and store the result. + hit, token = cache.get(raw_token) + if not hit: + token = await _call_upstream(raw_token) + if token is not None: + cache.set(raw_token, token) + ``` + + +## Classes + +### `TokenCache` + + +TTL-based in-memory cache for ``AccessToken`` objects. + +Features: +- SHA-256 hashed cache keys (fixed size, regardless of token length). +- Per-entry TTL that respects both the configured ``ttl_seconds`` and the + token's own ``expires_at`` claim (whichever is sooner). +- Bounded size with FIFO eviction when the cache is full. +- Periodic cleanup of expired entries to prevent unbounded growth. +- Defensive deep copies on both store and retrieve to prevent + callers from mutating cached values. + +Caching is disabled when ``ttl_seconds`` is ``None`` or ``0``, or +when ``max_size`` is ``0``. Negative values raise ``ValueError``. + + +**Methods:** + +#### `enabled` + +```python +enabled(self) -> bool +``` + +Return whether caching is active. + + +#### `get` + +```python +get(self, token: str) -> tuple[bool, AccessToken | None] +``` + +Look up a cached verification result. + +**Returns:** +- ``(True, AccessToken)`` on a cache hit, ``(False, None)`` on a miss +- or when caching is disabled. The returned ``AccessToken`` is a deep +- copy that is safe to mutate. + + +#### `set` + +```python +set(self, token: str, result: AccessToken) -> None +``` + +Store a *successful* verification result. + +Only successful verifications should be cached. Failures (inactive +tokens, missing scopes, HTTP errors, timeouts) must **not** be cached +so that transient problems do not produce sticky false negatives. + diff --git a/docs/python-sdk/fastmcp-utilities-types.mdx b/docs/python-sdk/fastmcp-utilities-types.mdx index 6cae8cdc0..562b6d961 100644 --- a/docs/python-sdk/fastmcp-utilities-types.mdx +++ b/docs/python-sdk/fastmcp-utilities-types.mdx @@ -29,7 +29,7 @@ However, this isn't feasible for user-generated functions. Instead, we use a cache to minimize the cost of creating them as much as possible. -### `issubclass_safe` +### `issubclass_safe` ```python issubclass_safe(cls: type, base: type) -> bool @@ -39,7 +39,7 @@ issubclass_safe(cls: type, base: type) -> bool Check if cls is a subclass of base, even if cls is a type variable. -### `is_class_member_of_type` +### `is_class_member_of_type` ```python is_class_member_of_type(cls: Any, base: type) -> bool @@ -52,7 +52,7 @@ Base can be a type, a UnionType, or an Annotated type. Generic types are not considered members (e.g. T is not a member of list\[T]). -### `find_kwarg_by_type` +### `find_kwarg_by_type` ```python find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None @@ -64,7 +64,7 @@ Find the name of the kwarg that is of type kwarg_type. Includes union types that contain the kwarg_type, as well as Annotated types. -### `create_function_without_params` +### `create_function_without_params` ```python create_function_without_params(fn: Callable[..., Any], exclude_params: list[str]) -> Callable[..., Any] @@ -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]) @@ -112,7 +112,7 @@ list[list[str]] Base model for FastMCP models. -### `Image` +### `Image` Helper class for returning images from tools. @@ -120,7 +120,7 @@ Helper class for returning images from tools. **Methods:** -#### `to_image_content` +#### `to_image_content` ```python to_image_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.ImageContent @@ -129,7 +129,7 @@ to_image_content(self, mime_type: str | None = None, annotations: Annotations | Convert to MCP ImageContent. -#### `to_data_uri` +#### `to_data_uri` ```python to_data_uri(self, mime_type: str | None = None) -> str @@ -138,7 +138,7 @@ to_data_uri(self, mime_type: str | None = None) -> str Get image as a data URI. -### `Audio` +### `Audio` Helper class for returning audio from tools. @@ -146,13 +146,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. @@ -160,10 +160,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/python-sdk/fastmcp-utilities-versions.mdx b/docs/python-sdk/fastmcp-utilities-versions.mdx index f5e44f296..c571c571f 100644 --- a/docs/python-sdk/fastmcp-utilities-versions.mdx +++ b/docs/python-sdk/fastmcp-utilities-versions.mdx @@ -22,7 +22,7 @@ Examples: ## Functions -### `parse_version_key` +### `parse_version_key` ```python parse_version_key(version: str | None) -> VersionKey @@ -38,7 +38,7 @@ Parse a version string into a sortable key. - A VersionKey suitable for sorting. -### `version_sort_key` +### `version_sort_key` ```python version_sort_key(component: FastMCPComponent) -> VersionKey @@ -56,7 +56,7 @@ Use with sorted() or max() to order components by version. - A sortable VersionKey. -### `compare_versions` +### `compare_versions` ```python compare_versions(a: str | None, b: str | None) -> int @@ -73,7 +73,7 @@ Compare two version strings. - -1 if a < b, 0 if a == b, 1 if a > b. -### `is_version_greater` +### `is_version_greater` ```python is_version_greater(a: str | None, b: str | None) -> bool @@ -90,7 +90,7 @@ Check if version a is greater than version b. - True if a > b, False otherwise. -### `max_version` +### `max_version` ```python max_version(a: str | None, b: str | None) -> str | None @@ -107,7 +107,7 @@ Return the greater of two versions. - The greater version, or None if both are None. -### `min_version` +### `min_version` ```python min_version(a: str | None, b: str | None) -> str | None @@ -124,9 +124,29 @@ Return the lesser of two versions. - The lesser version, or None if both are None. +### `dedupe_with_versions` + +```python +dedupe_with_versions(components: Sequence[C], key_fn: Callable[[C], str]) -> list[C] +``` + + +Deduplicate components by key, keeping highest version. + +Groups components by key, selects the highest version from each group, +and injects available versions into meta if any component is versioned. + +**Args:** +- `components`: Sequence of components to deduplicate. +- `key_fn`: Function to extract the grouping key from a component. + +**Returns:** +- Deduplicated list with versions injected into meta. + + ## Classes -### `VersionSpec` +### `VersionSpec` Specification for filtering components by version. @@ -143,7 +163,7 @@ match any spec. **Methods:** -#### `matches` +#### `matches` ```python matches(self, version: str | None) -> bool @@ -162,7 +182,7 @@ from version-specific rules. - True if the version matches the spec. -#### `intersect` +#### `intersect` ```python intersect(self, other: VersionSpec | None) -> VersionSpec @@ -181,7 +201,7 @@ the intersection validates "1.0" is in range and returns the exact spec. - A VersionSpec that matches only versions satisfying both specs. -### `VersionKey` +### `VersionKey` A comparable version key that handles None, PEP 440 versions, and strings. diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index e8e79507c..19f5c2cee 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -3,7 +3,6 @@ title: OAuth Proxy sidebarTitle: OAuth Proxy description: Bridge traditional OAuth providers to work seamlessly with MCP's authentication flow. icon: share -tag: NEW --- import { VersionBadge } from "/snippets/version-badge.mdx"; @@ -100,8 +99,11 @@ mcp = FastMCP(name="My Server", auth=auth) Client ID from your registered OAuth application - - Client secret from your registered OAuth application + + Client secret from your registered OAuth application. Optional for PKCE public + clients or when using alternative credentials (e.g., managed identity client + assertions via a subclass). When omitted, `jwt_signing_key` must be provided + explicitly since it cannot be derived from the secret. diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index 86661bc16..73ab43e73 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -70,8 +70,9 @@ mcp = FastMCP(name="My Server", auth=auth) Client ID from your registered OAuth application - - Client secret from your registered OAuth application + + Client secret from your registered OAuth application. Optional for PKCE public + clients. When omitted, `jwt_signing_key` must be provided. diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx index d13f43952..40fc7b65b 100644 --- a/docs/servers/dependency-injection.mdx +++ b/docs/servers/dependency-injection.mdx @@ -160,14 +160,20 @@ def get_client_ip() -> str: ``` -Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport). Use HTTP Headers if you need graceful fallback. +Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport). +For background tasks created from an HTTP request, FastMCP restores a minimal request +backed by the originating request's snapshotted headers. Use HTTP Headers if you need +graceful fallback. ### HTTP Headers -Access HTTP headers with graceful fallback—returns an empty dictionary when no HTTP request is available, making it safe for code that might run over any transport. +Access HTTP headers with graceful fallback. When a background task originates from an +HTTP request, FastMCP restores the originating headers inside the worker. When no HTTP +request is available, this returns an empty dictionary, making it safe for code that +might run over any transport. **Dependency injection:** Use `CurrentHeaders()`: diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index 40449ae10..1a18d89e3 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -421,11 +421,21 @@ Each settings class accepts: For persistence or distributed deployments, configure a different storage backend: ```python +from pathlib import Path from fastmcp.server.middleware.caching import ResponseCachingMiddleware -from key_value.aio.stores.disk import DiskStore +from key_value.aio.stores.filetree import ( + FileTreeStore, + FileTreeV1KeySanitizationStrategy, + FileTreeV1CollectionSanitizationStrategy, +) +cache_dir = Path("cache") mcp.add_middleware(ResponseCachingMiddleware( - cache_storage=DiskStore(directory="cache") + cache_storage=FileTreeStore( + data_directory=cache_dir, + key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(cache_dir), + collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(cache_dir), + ) )) ``` @@ -535,30 +545,6 @@ mcp.add_middleware(PingMiddleware(interval_ms=5000)) The ping task starts on the first message and stops automatically when the session ends. Most useful for stateful HTTP connections; has no effect on stateless connections. -### Tool Injection - -```python -from fastmcp.server.middleware.tool_injection import ( - ToolInjectionMiddleware, - PromptToolMiddleware, - ResourceToolMiddleware -) -``` - -`ToolInjectionMiddleware` dynamically injects tools during request processing. `PromptToolMiddleware` and `ResourceToolMiddleware` provide compatibility layers for clients that cannot list or access prompts and resources directly—they expose those capabilities as tools. - -```python -from fastmcp import FastMCP -from fastmcp.tools import Tool -from fastmcp.server.middleware.tool_injection import ToolInjectionMiddleware - -def my_tool_fn(a: int, b: int) -> int: - return a + b - -my_tool = Tool.from_function(fn=my_tool_fn, name="my_tool") -mcp.add_middleware(ToolInjectionMiddleware(tools=[my_tool])) -``` - ### Response Limiting diff --git a/docs/servers/providers/proxy.mdx b/docs/servers/providers/proxy.mdx index a2def6892..c870c97f8 100644 --- a/docs/servers/providers/proxy.mdx +++ b/docs/servers/providers/proxy.mdx @@ -258,7 +258,50 @@ Proxying introduces network latency: When mounting proxy servers, this latency affects all operations on the parent server. -For low-latency requirements, consider caching strategies or limiting mounting depth. +### Component List Caching + + + +`ProxyProvider` caches the backend's component lists (tools, resources, templates, prompts) so that individual lookups — like resolving a tool by name during `call_tool` — don't require a separate backend connection. The cache stores raw component metadata and is shared across all proxy sessions; per-session visibility, auth, and transforms are still applied after cache lookup by the server layer. The cache refreshes whenever an explicit `list_*` call is made, and entries expire after a configurable TTL (default 300 seconds). + +For backends whose component lists change dynamically, disable caching by setting `cache_ttl=0`. + +```python +from fastmcp.server.providers.proxy import ProxyProvider, ProxyClient + +# Default 300s TTL +provider = ProxyProvider(lambda: ProxyClient("http://backend/mcp")) + +# Custom TTL +provider = ProxyProvider(lambda: ProxyClient("http://backend/mcp"), cache_ttl=60) + +# Disable caching +provider = ProxyProvider(lambda: ProxyClient("http://backend/mcp"), cache_ttl=0) +``` + +### Session Reuse for Stateless Backends + +By default, each tool call opens a fresh MCP session to the backend. This is the safe default because it prevents state from leaking between requests. However, for stateless HTTP backends where there's no session state to protect, this overhead is unnecessary. + +You can reuse a single backend session by providing a client factory that returns the same client instance: + +```python +from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient + +base_client = ProxyClient("http://backend:8000/mcp") +shared_client = base_client.new() + +proxy = FastMCPProxy( + client_factory=lambda: shared_client, + name="ReusedSessionProxy", +) +``` + +This eliminates the MCP initialization handshake on every call, which can dramatically reduce latency under load. The `Client` uses reference counting for its session lifecycle, so concurrent callers sharing the same instance is safe. + + +Only reuse sessions when you know the backend is stateless (e.g. stateless HTTP). For stateful backends (stdio processes, servers that track session state), use the default fresh-session behavior to avoid context mixing. + ## Advanced Usage diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 0a8a772aa..77843f252 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -349,7 +349,7 @@ if data_dir_path.is_dir(): - `TextResource`: For simple string content. - `BinaryResource`: For raw `bytes` content. -- `FileResource`: Reads content from a local file path. Handles text/binary modes and lazy reading. +- `FileResource`: Reads content from a local file path. Handles text/binary modes, encoding, and lazy reading. - `HttpResource`: Fetches content from an HTTP(S) URL (requires `httpx`). - `DirectoryResource`: Lists files in a local directory (returns JSON). - (`FunctionResource`: Internal class used by `@mcp.resource`). diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index ed84b5e48..6b192a3b0 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -11,36 +11,105 @@ The `FastMCP` class is the central piece of every FastMCP application. It acts a ## Creating a Server -Instantiate a server by providing a name that identifies it in client applications and logs. You can also provide instructions that help clients understand the server's purpose. +At its simplest, a FastMCP server just needs a name. Everything else has sensible defaults. ```python from fastmcp import FastMCP -mcp = FastMCP(name="MyAssistantServer") +mcp = FastMCP("MyServer") +``` -# Instructions help clients understand how to interact with the server -mcp_with_instructions = FastMCP( - name="HelpfulAssistant", - instructions=""" - This server provides data analysis tools. - Call get_average() to analyze numerical data. - """, +Instructions help clients (and the LLMs behind them) understand what your server does and how to use it effectively. + +```python +mcp = FastMCP( + "DataAnalysis", + instructions="Provides tools for analyzing numerical datasets. Start with get_summary() for an overview.", ) ``` -The `FastMCP` constructor accepts several configuration options. The most commonly used parameters control server identity, authentication, and component behavior. +## Components - +FastMCP servers expose three types of components to clients, each serving a distinct role in the MCP protocol. + +**Tools** are functions that clients invoke to perform actions or access external systems. + +```python +@mcp.tool +def multiply(a: float, b: float) -> float: + """Multiplies two numbers together.""" + return a * b +``` + +**Resources** expose data that clients can read — passive data sources rather than invocable functions. + +```python +@mcp.resource("data://config") +def get_config() -> dict: + return {"theme": "dark", "version": "1.0"} +``` + +**Prompts** are reusable message templates that guide LLM interactions. + +```python +@mcp.prompt +def analyze_data(data_points: list[float]) -> str: + formatted_data = ", ".join(str(point) for point in data_points) + return f"Please analyze these data points: {formatted_data}" +``` + +Each component type has detailed documentation: [Tools](/servers/tools), [Resources](/servers/resources) (including [Resource Templates](/servers/resources#resource-templates)), and [Prompts](/servers/prompts). + +## Running the Server + +Start your server by calling `mcp.run()`. The `if __name__` guard ensures compatibility with MCP clients that launch your server as a subprocess. + +```python +from fastmcp import FastMCP + +mcp = FastMCP("MyServer") + +@mcp.tool +def greet(name: str) -> str: + """Greet a user by name.""" + return f"Hello, {name}!" + +if __name__ == "__main__": + mcp.run() +``` + +FastMCP supports several transports: +- **STDIO** (default): For local integrations and CLI tools +- **HTTP**: For web services using the Streamable HTTP protocol +- **SSE**: Legacy web transport (deprecated) + +```python +# Run with HTTP transport +mcp.run(transport="http", host="127.0.0.1", port=9000) +``` + +The server can also be run using the FastMCP CLI. For detailed information on transports and deployment, see [Running Your Server](/deployment/running-server). + + +## Configuration Reference + +The `FastMCP` constructor accepts parameters organized into four categories: identity, composition, behavior, and handlers. + +### Identity + +These parameters control how your server presents itself to clients. + + - A human-readable name for your server + A human-readable name for your server, shown in client applications and logs - Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality + Description of how to interact with this server. Clients surface these instructions to help LLMs understand the server's purpose and available functionality - Version string for your server. If not provided, defaults to the FastMCP library version + Version string for your server. Defaults to the FastMCP library version if not provided @@ -52,108 +121,106 @@ The `FastMCP` constructor accepts several configuration options. The most common - List of icon representations for your server. Icons help users visually identify your server in client applications. See [Icons](/servers/icons) for detailed examples + List of icon representations for your server. See [Icons](/servers/icons) for details + + + +### Composition + +These parameters control what your server is built from — its components, middleware, providers, and lifecycle. + + + + Tools to register on the server. An alternative to the `@mcp.tool` decorator when you need to add tools programmatically - Authentication provider for securing HTTP-based transports. See [Authentication](/servers/auth/authentication) for configuration options + Authentication provider for securing HTTP-based transports. See [Authentication](/servers/auth/authentication) for configuration - - Server-level setup and teardown logic. See [Lifespans](/servers/lifespan) for composable lifespans + + [Middleware](/servers/middleware) that intercepts and transforms every MCP message flowing through the server — requests, responses, and notifications in both directions. Use for cross-cutting concerns like logging, error handling, and rate limiting - - A list of tools (or functions to convert to tools) to add to the server. In some cases, providing tools programmatically may be more convenient than using the `@mcp.tool` decorator + + [Providers](/servers/providers) that supply tools, resources, and prompts dynamically. Providers are queried at request time, so they can serve components from databases, APIs, or other external sources - Server-level [transforms](/servers/transforms/transforms) to apply to all components. Transforms modify how tools, resources, and prompts are presented to clients — for example, [search transforms](/servers/transforms/tool-search) replace large catalogs with on-demand discovery, and [CodeMode](/servers/transforms/code-mode) lets LLMs write scripts that chain tool calls in a sandbox + Server-level [transforms](/servers/transforms/transforms) to apply to all components. Transforms modify how tools, resources, and prompts are presented to clients — for example, [search transforms](/servers/transforms/tool-search) replace large catalogs with on-demand discovery + + Server-level setup and teardown logic that runs when the server starts and stops. See [Lifespans](/servers/lifespan) for composable lifespans + + + +### Behavior + +These parameters tune how the server processes requests and communicates with clients. + + How to handle duplicate component registrations - - Controls how tool input parameters are validated. When `False` (default), FastMCP uses Pydantic's flexible validation that coerces compatible inputs (e.g., `"10"` to `10` for int parameters). When `True`, uses the MCP SDK's JSON Schema validation to validate inputs against the exact schema before passing them to your function, rejecting any type mismatches. The default mode improves compatibility with LLM clients while maintaining type safety. See [Input Validation Modes](/servers/tools#input-validation-modes) for details + + When `False` (default), FastMCP uses Pydantic's flexible validation that coerces compatible inputs (e.g., `"10"` → `10` for int parameters). When `True`, validates inputs against the exact JSON Schema before calling your function, rejecting type mismatches. See [Input Validation Modes](/servers/tools#input-validation-modes) for details + + + + When `True`, replaces internal error details in tool/resource responses with a generic message to avoid leaking implementation details to clients. Defaults to the `FASTMCP_MASK_ERROR_DETAILS` environment variable - Maximum number of items per page for list operations (`tools/list`, `resources/list`, etc.). When `None` (default), all results are returned in a single response. When set, responses are paginated and include a `nextCursor` for fetching additional pages. See [Pagination](/servers/pagination) for details + + Maximum items per page for list operations (`tools/list`, `resources/list`, etc.). When `None`, all results are returned in a single response. See [Pagination](/servers/pagination) for details + + Enable background task support. When `True`, tools and resources can return `CreateTaskResult` to run work asynchronously while the client polls for results + + + + + + 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"` + + + + Automatically dereference `$ref` pointers in JSON schemas generated from complex Pydantic models. Most clients require flat schemas without `$ref`, so this should usually stay enabled + -## Components +### Handlers and Storage -FastMCP servers expose three types of components to clients. Each type serves a distinct purpose in the MCP protocol. +These parameters provide custom handlers for MCP capabilities and persistent storage for session state. -### Tools + + + Custom handler for MCP sampling requests (server-initiated LLM calls). See [Sampling](/servers/sampling) for details + -Tools are functions that clients can invoke to perform actions or access external systems. They're the primary way clients interact with your server's capabilities. + + When `"fallback"`, the sampling handler is used only when no tool-specific handler exists. When `"always"`, this handler is used for all sampling requests + -```python -@mcp.tool -def multiply(a: float, b: float) -> float: - """Multiplies two numbers together.""" - return a * b -``` + + 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 + + -See [Tools](/servers/tools) for detailed documentation. - -### Resources - -Resources expose data that clients can read. Unlike tools, resources are passive data sources that clients pull from rather than invoke. - -```python -@mcp.resource("data://config") -def get_config() -> dict: - """Provides the application configuration.""" - return {"theme": "dark", "version": "1.0"} -``` - -See [Resources](/servers/resources) for detailed documentation. - -### Resource Templates - -Resource templates are parameterized resources. The client provides values for template parameters in the URI, and the server returns data specific to those parameters. - -```python -@mcp.resource("users://{user_id}/profile") -def get_user_profile(user_id: int) -> dict: - """Retrieves a user's profile by ID.""" - return {"id": user_id, "name": f"User {user_id}", "status": "active"} -``` - -See [Resource Templates](/servers/resources#resource-templates) for detailed documentation. - -### Prompts - -Prompts are reusable message templates that guide LLM interactions. They help establish consistent patterns for how clients should frame requests. - -```python -@mcp.prompt -def analyze_data(data_points: list[float]) -> str: - """Creates a prompt asking for analysis of numerical data.""" - formatted_data = ", ".join(str(point) for point in data_points) - return f"Please analyze these data points: {formatted_data}" -``` - -See [Prompts](/servers/prompts) for detailed documentation. ## Tag-Based Filtering -Tags let you categorize components and selectively expose them based on configurable include/exclude sets. This is useful for creating different views of your server for different environments or user types. - -Components can be tagged when defined using the `tags` parameter. A component can have multiple tags, and filtering operates on tag membership. +Tags let you categorize components and selectively expose them. This is useful for creating different views of your server for different environments or user types. ```python @mcp.tool(tags={"public", "utility"}) @@ -174,8 +241,6 @@ The filtering logic works as follows: To ensure a component is never exposed, you can set `enabled=False` on the component itself. See the component-specific documentation for details. -Configure tag-based filtering after creating your server. - ```python # Only expose components tagged with "public" mcp = FastMCP() @@ -192,38 +257,9 @@ mcp.enable(tags={"admin"}, only=True).disable(tags={"deprecated"}) This filtering applies to all component types (tools, resources, resource templates, and prompts) and affects both listing and access. -## Running the Server - -FastMCP servers communicate with clients through transport mechanisms. Start your server by calling `mcp.run()`, typically within an `if __name__ == "__main__":` block. This pattern ensures compatibility with various MCP clients. - -```python -from fastmcp import FastMCP - -mcp = FastMCP(name="MyServer") - -@mcp.tool -def greet(name: str) -> str: - """Greet a user by name.""" - return f"Hello, {name}!" - -if __name__ == "__main__": - # Defaults to STDIO transport - mcp.run() - - # Or use HTTP transport - # mcp.run(transport="http", host="127.0.0.1", port=9000) -``` - -FastMCP supports several transports: -- **STDIO** (default): For local integrations and CLI tools -- **HTTP**: For web services using the Streamable HTTP protocol -- **SSE**: Legacy web transport (deprecated) - -The server can also be run using the FastMCP CLI. For detailed information on transports and configuration, see the [Running Your Server](/deployment/running-server) guide. - ## Custom Routes -When running with HTTP transport, you can add custom web routes alongside your MCP endpoint using the `@custom_route` decorator. This is useful for auxiliary endpoints like health checks. +When running with HTTP transport, you can add custom web routes alongside your MCP endpoint using the `@custom_route` decorator. ```python from fastmcp import FastMCP @@ -240,9 +276,4 @@ if __name__ == "__main__": mcp.run(transport="http") # Health check at http://localhost:8000/health ``` -Custom routes are served alongside your MCP endpoint and are useful for: -- Health check endpoints for monitoring -- Simple status or info endpoints -- Basic webhooks or callbacks - -For more complex web applications, consider [mounting your MCP server into a FastAPI or Starlette app](/deployment/http#integration-with-web-frameworks). +Custom routes are useful for health checks, status endpoints, and simple webhooks. For more complex web applications, consider [mounting your MCP server into a FastAPI or Starlette app](/deployment/http#integration-with-web-frameworks). diff --git a/docs/servers/storage-backends.mdx b/docs/servers/storage-backends.mdx index 1bdb19b20..e743b5dab 100644 --- a/docs/servers/storage-backends.mdx +++ b/docs/servers/storage-backends.mdx @@ -64,7 +64,9 @@ store = FileTreeStore( middleware = ResponseCachingMiddleware(cache_storage=store) ``` -The sanitization strategies ensure keys and collection names are safe for the filesystem — alphanumeric names pass through as-is for readability, while special characters are hashed to prevent path traversal. + +**Sanitization strategies are required** when using `FileTreeStore`. Without them, keys containing special characters (such as URL-based OAuth client IDs like `https://claude.ai/oauth/claude-code-client-metadata`) will be used as-is in filesystem paths, causing `FileNotFoundError` crashes. The V1 strategies shown above are safe defaults — alphanumeric names pass through as-is for readability, while special characters are hashed to prevent path errors and traversal attacks. Changing sanitization strategies after data has been written is a breaking change, so choose your strategy upfront. + **Characteristics:** - ✅ Data persists across restarts diff --git a/docs/servers/transforms/prompts-as-tools.mdx b/docs/servers/transforms/prompts-as-tools.mdx index b68c0891d..6a9ab1b47 100644 --- a/docs/servers/transforms/prompts-as-tools.mdx +++ b/docs/servers/transforms/prompts-as-tools.mdx @@ -21,7 +21,11 @@ This means any client that can call tools can now access prompts, even if the cl ## Basic Usage -Pass your server to `PromptsAsTools` when adding the transform. The transform queries that server for prompts whenever the generated tools are called. +Pass your FastMCP server to `PromptsAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to prompt operations automatically, exactly as it would for direct `prompts/get` calls. + + +`PromptsAsTools` (and `ResourcesAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of prompts, create a dedicated FastMCP server for those prompts and apply the transform there. + ```python from fastmcp import FastMCP diff --git a/docs/servers/transforms/resources-as-tools.mdx b/docs/servers/transforms/resources-as-tools.mdx index 8c7e8bbed..b79980dcc 100644 --- a/docs/servers/transforms/resources-as-tools.mdx +++ b/docs/servers/transforms/resources-as-tools.mdx @@ -21,7 +21,11 @@ This means any client that can call tools can now access resources, even if the ## Basic Usage -Pass your server to `ResourcesAsTools` when adding the transform. The transform queries that server for resources whenever the generated tools are called. +Pass your FastMCP server to `ResourcesAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to resource operations automatically, exactly as it would for direct `resources/read` calls. + + +`ResourcesAsTools` (and `PromptsAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of resources, create a dedicated FastMCP server for those resources and apply the transform there. + ```python from fastmcp import FastMCP @@ -45,6 +49,8 @@ mcp.add_transform(ResourcesAsTools(mcp)) Clients now see three tools: whatever tools you defined directly, plus `list_resources` and `read_resource`. +Both generated tools are annotated with `readOnlyHint=True`, since they only read data. Clients that respect tool annotations (like Cursor) can use this to auto-confirm these tool calls without prompting the user. + ## Static Resources vs Templates Resources come in two forms, and the `list_resources` tool distinguishes between them in its JSON output. diff --git a/docs/unify-intent.js b/docs/unify-intent.js index 25eafe494..b51e57b5a 100644 --- a/docs/unify-intent.js +++ b/docs/unify-intent.js @@ -1,10 +1,16 @@ -// Load Unify intent tag on authentication pages only +// Load Unify intent tag on selected pages (function () { if (typeof window === "undefined") return; - function isAuthPage() { + function isTaggedPage() { var path = window.location.pathname; - return path.includes("/servers/auth/") || path.includes("/clients/auth/"); + return ( + path.includes("/servers/auth/") || + path.includes("/clients/auth/") || + path.includes("/deployment/running-server") || + path.includes("/deployment/http") || + path.includes("/deployment/prefect-horizon") + ); } function loadUnify() { @@ -45,9 +51,9 @@ } function update() { - if (isAuthPage() && !document.getElementById("unifytag")) { + if (isTaggedPage() && !document.getElementById("unifytag")) { loadUnify(); - } else if (!isAuthPage() && document.getElementById("unifytag")) { + } else if (!isTaggedPage() && document.getElementById("unifytag")) { document.getElementById("unifytag").remove(); } } diff --git a/docs/updates.mdx b/docs/updates.mdx index e3134fb61..394bcdb02 100644 --- a/docs/updates.mdx +++ b/docs/updates.mdx @@ -5,6 +5,26 @@ icon: "sparkles" tag: NEW --- + + +Pins `pydantic-monty<0.0.8` to fix a breaking change in Monty that affects code mode. + + + + + +The Code Mode release. Instead of loading the entire tool catalog into context, `CodeMode` gives LLMs meta-tools: search for relevant tools on demand, inspect their schemas, then write Python that chains `call_tool()` calls in a sandbox. Also ships search transforms, early Prefab Apps integration, `MultiAuth` for composing multiple token verification sources, and PropelAuth support. + + + + + +v2.14.4 backported `dereference_refs()` but never wired it into the tool schema pipeline — `$ref` and `$defs` were still sent to MCP clients. Now fixed: schemas are fully inlined before reaching clients. + + + + +**[v2.14.6: $Ref Dead Redemption](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.6)** + +v2.14.4 backported `dereference_refs()` but never wired it into the tool schema pipeline — `$ref` and `$defs` were still sent to MCP clients. Now fixed: `compress_schema()` dereferences at both tool schema creation sites, so schemas are fully inlined before reaching clients. + +## What's Changed +### Fixes 🐞 +* Updated deprecation URL for V2 by [@SrzStephen](https://github.com/SrzStephen) in [#3109](https://github.com/PrefectHQ/fastmcp/pull/3109) +* Use MemoryStore for OAuth proxy tests by [@SrzStephen](https://github.com/SrzStephen) in [#3111](https://github.com/PrefectHQ/fastmcp/pull/3111) +* fix: wire up dereference_refs() in tool schema pipeline by [@jlowin](https://github.com/jlowin) in [#3170](https://github.com/PrefectHQ/fastmcp/pull/3170) + +**Full Changelog**: [v2.14.5...v2.14.6](https://github.com/PrefectHQ/fastmcp/compare/v2.14.5...v2.14.6) + + + **[v2.14.5: Sealed Docket](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.5)** diff --git a/docs/v2/updates.mdx b/docs/v2/updates.mdx index 65e212e8d..a59a6fc6a 100644 --- a/docs/v2/updates.mdx +++ b/docs/v2/updates.mdx @@ -5,6 +5,16 @@ icon: "sparkles" tag: NEW --- + + +v2.14.4 backported `dereference_refs()` but never wired it into the tool schema pipeline — `$ref` and `$defs` were still sent to MCP clients. Now fixed: schemas are fully inlined before reaching clients. + + + list[dict]: + return [r for r in _requests if r["status"] == status] + + +def _find_request(request_id: str) -> dict | None: + for r in _requests: + if r["id"] == request_id: + return r + return None + + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +app = FastMCPApp("Approvals") + + +def _all_lists() -> dict[str, list[dict]]: + """Return state updates for all three status lists.""" + return { + "pending_requests": _by_status("pending"), + "approved_requests": _by_status("approved"), + "rejected_requests": _by_status("rejected"), + } + + +@app.tool() +def approve_request(request_id: str) -> dict[str, list[dict]]: + """Approve a pending request and return updated lists.""" + req = _find_request(request_id) + if req is None: + raise ValueError(f"Request {request_id} not found") + if req["status"] != "pending": + raise ValueError(f"Request {request_id} is already {req['status']}") + req["status"] = "approved" + return _all_lists() + + +@app.tool() +def reject_request(request_id: str) -> dict[str, list[dict]]: + """Reject a pending request and return updated lists.""" + req = _find_request(request_id) + if req is None: + raise ValueError(f"Request {request_id} not found") + if req["status"] != "pending": + raise ValueError(f"Request {request_id} is already {req['status']}") + req["status"] = "rejected" + return _all_lists() + + +@app.tool() +def add_comment(request_id: str, comment: str) -> dict: + """Add a comment to a request. Returns the updated request.""" + req = _find_request(request_id) + if req is None: + raise ValueError(f"Request {request_id} not found") + comments = req.setdefault("comments", []) + comments.append(comment) + return req + + +@app.tool(model=True) +def get_request_details(request_id: str) -> dict: + """Get full details for a single request. Available to both model and UI.""" + req = _find_request(request_id) + if req is None: + raise ValueError(f"Request {request_id} not found") + return req + + +@app.tool() +def list_requests(status: str | None = None) -> list[dict]: + """List requests, optionally filtered by status.""" + if status is not None: + return _by_status(status) + return list(_requests) + + +def _update_all_lists() -> list: + """Actions to update all three status lists from a tool result.""" + return [ + SetState("pending_requests", RESULT.pending_requests), + SetState("approved_requests", RESULT.approved_requests), + SetState("rejected_requests", RESULT.rejected_requests), + ] + + +def _build_request_card( + item: Rx, + *, + status_variant: str = "warning", + show_actions: bool = False, +) -> None: + """Build a card for a single request inside a ForEach context.""" + request_id = str(item.id) + + with Card(): + with CardHeader(): + with Row(gap=2, align="center", justify="between"): + CardTitle(item.title) + Badge(item.status, variant=status_variant) + with CardContent(css_class="space-y-2"): + with Row(gap=2, align="center"): + Badge(item.type, variant="secondary") + Text(item.submitter, css_class="font-medium") + Muted(item.created_at) + + with If(item.amount): + Text(item.amount.currency(), css_class="text-lg font-semibold") + + Muted(item.description) + + if show_actions: + Separator() + with Row(gap=2): + Button( + "Approve", + variant="default", + on_click=CallTool( + approve_request, + arguments={"request_id": request_id}, + on_success=_update_all_lists() + + [ + ShowToast( + "Request approved", + variant="success", + ), + ], + on_error=ShowToast( + ERROR, + variant="error", + ), + ), + ) + Button( + "Reject", + variant="destructive", + on_click=CallTool( + reject_request, + arguments={"request_id": request_id}, + on_success=_update_all_lists() + + [ + ShowToast( + "Request rejected", + variant="warning", + ), + ], + on_error=ShowToast( + ERROR, + variant="error", + ), + ), + ) + + +@app.ui() +def approval_dashboard() -> PrefabApp: + """Open the approval dashboard. The model calls this to launch the app.""" + pending_count = Rx("pending_requests").length() + approved_count = Rx("approved_requests").length() + rejected_count = Rx("rejected_requests").length() + + with Column(gap=6, css_class="p-6") as view: + with Row(gap=3, align="center"): + Heading("Approval Dashboard") + Badge(pending_count, variant="warning") + Muted("pending") + + with Tabs(value="pending"): + with Tab(title="Pending"): + with If(pending_count): + with ForEach("pending_requests") as item: + _build_request_card(item, show_actions=True) + with If(~pending_count): + Muted("No pending requests.") + + with Tab(title="Approved"): + with If(approved_count): + with ForEach("approved_requests") as item: + _build_request_card(item, status_variant="success") + with If(~approved_count): + Muted("No approved requests.") + + with Tab(title="Rejected"): + with If(rejected_count): + with ForEach("rejected_requests") as item: + _build_request_card(item, status_variant="destructive") + with If(~rejected_count): + Muted("No rejected requests.") + + return PrefabApp( + view=view, + state={ + "pending_requests": _by_status("pending"), + "approved_requests": _by_status("approved"), + "rejected_requests": _by_status("rejected"), + }, + ) + + +mcp = FastMCP("Approvals Server", providers=[app]) + +if __name__ == "__main__": + mcp.run(transport="http") diff --git a/examples/apps/chart_server.py b/examples/apps/chart_server.py index 94130f777..566cd3ea4 100644 --- a/examples/apps/chart_server.py +++ b/examples/apps/chart_server.py @@ -1,33 +1,11 @@ -"""Chart MCP App — interactive data visualizations with Prefab. - -Demonstrates `fastmcp[apps]` with Prefab chart components: -- `BarChart` and `LineChart` for categorical and trend data -- Multiple series, stacking, and curve styles -- Layout composition with `Column`, `Heading`, and `Muted` -- Custom text fallback via `ToolResult` - -Usage: - uv run python chart_server.py # HTTP (port 8000) - uv run python chart_server.py --stdio # stdio for MCP clients -""" - -from __future__ import annotations - -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - BarChart, - ChartSeries, - Column, - Heading, - LineChart, - Muted, -) +from prefab_ui.components import Column, Heading, Muted +from prefab_ui.components.charts import BarChart, ChartSeries from fastmcp import FastMCP mcp = FastMCP("Sales Dashboard") -MONTHLY_SALES = [ +DATA = [ {"month": "Jan", "online": 4200, "retail": 2400}, {"month": "Feb", "online": 3800, "retail": 2100}, {"month": "Mar", "online": 5100, "retail": 2800}, @@ -38,21 +16,13 @@ MONTHLY_SALES = [ @mcp.tool(app=True) -def sales_overview(stacked: bool = False) -> PrefabApp: - """View monthly sales broken down by channel. - - Args: - stacked: Stack bars to show total revenue per month. - """ - total = sum(row["online"] + row["retail"] for row in MONTHLY_SALES) - - with Column(gap=6, css_class="p-6") as view: - with Column(gap=1): - Heading("Monthly Sales") - Muted(f"${total:,} total revenue") - +def sales_chart(stacked: bool = False) -> Column: + """Show monthly online vs. retail sales as a bar chart.""" + with Column(gap=4, css_class="p-6") as view: + Heading("Monthly Sales") + Muted("Online vs. retail — hover bars for details") BarChart( - data=MONTHLY_SALES, + data=DATA, series=[ ChartSeries(data_key="online", label="Online"), ChartSeries(data_key="retail", label="Retail"), @@ -61,41 +31,7 @@ def sales_overview(stacked: bool = False) -> PrefabApp: stacked=stacked, show_legend=True, ) - - return PrefabApp( - title="Sales Dashboard", - view=view, - ) - - -@mcp.tool(app=True) -def sales_trend(curve: str = "linear") -> PrefabApp: - """View sales trends over time as a line chart. - - Args: - curve: Line style — "linear", "smooth", or "step". - """ - with Column(gap=6, css_class="p-6") as view: - with Column(gap=1): - Heading("Sales Trend") - Muted("Online vs. retail over 6 months") - - LineChart( - data=MONTHLY_SALES, - series=[ - ChartSeries(data_key="online", label="Online"), - ChartSeries(data_key="retail", label="Retail"), - ], - x_axis="month", - curve=curve, - show_dots=True, - show_legend=True, - ) - - return PrefabApp( - title="Sales Trend", - view=view, - ) + return view if __name__ == "__main__": diff --git a/examples/apps/choice/choice_server.py b/examples/apps/choice/choice_server.py new file mode 100644 index 000000000..b91dfb726 --- /dev/null +++ b/examples/apps/choice/choice_server.py @@ -0,0 +1,13 @@ +"""Multiple choice — let the user pick from options instead of typing. + +Usage: + uv run python choice_server.py +""" + +from fastmcp import FastMCP +from fastmcp.apps.choice import Choice + +mcp = FastMCP("Choice Demo", providers=[Choice()]) + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/contacts/contacts_server.py b/examples/apps/contacts/contacts_server.py new file mode 100644 index 000000000..66c2ba597 --- /dev/null +++ b/examples/apps/contacts/contacts_server.py @@ -0,0 +1,148 @@ +"""Contact manager — a FastMCPApp example with forms and callable tool references. + +Demonstrates the full FastMCPApp stack: +- @app.ui() entry point that the model calls to open the app +- @app.tool() backend tools that the UI calls via CallTool +- CallTool(fn) with function references (not strings) that resolve to global keys +- Form.from_model() for auto-generated Pydantic model forms +- Manual form construction with the context-manager pattern + +Usage: + uv run python contacts_server.py +""" + +from __future__ import annotations + +from typing import Literal + +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.actions.mcp import CallTool +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Button, + Column, + ForEach, + Form, + Heading, + Input, + Muted, + Row, + Separator, + Text, +) +from prefab_ui.rx import ERROR, RESULT, STATE +from pydantic import BaseModel, Field + +from fastmcp import FastMCP, FastMCPApp + +# --------------------------------------------------------------------------- +# Data +# --------------------------------------------------------------------------- + +_contacts: list[dict] = [ + { + "name": "Arthur Dent", + "email": "arthur@earth.com", + "category": "Customer", + "notes": "", + }, + { + "name": "Ford Prefect", + "email": "ford@betelgeuse.org", + "category": "Partner", + "notes": "Researcher", + }, +] + + +# --------------------------------------------------------------------------- +# Pydantic model for auto-generated forms +# --------------------------------------------------------------------------- + + +class ContactModel(BaseModel): + name: str = Field(title="Full Name", min_length=1) + email: str = Field(title="Email") + category: Literal["Customer", "Vendor", "Partner", "Other"] = "Other" + notes: str = Field( + default="", + title="Notes", + json_schema_extra={"ui": {"type": "textarea"}}, + ) + + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +app = FastMCPApp("Contacts") + + +@app.tool() +def save_contact(data: ContactModel) -> list[dict]: + """Save a new contact and return the updated list.""" + _contacts.append(data.model_dump()) + return list(_contacts) + + +@app.tool() +def search_contacts(query: str) -> list[dict]: + """Filter contacts by name or email.""" + q = query.lower() + return [c for c in _contacts if q in c["name"].lower() or q in c["email"].lower()] + + +@app.tool(model=True) +def list_contacts() -> list[dict]: + """Return all contacts. Visible to both the model and the UI.""" + return list(_contacts) + + +@app.ui() +def contact_manager() -> PrefabApp: + """Open the contact manager. The model calls this to launch the app.""" + with Column(gap=6, css_class="p-6") as view: + Heading("Contacts") + + with ForEach("contacts") as contact: + with Row(gap=2, align="center"): + Text(contact.name, css_class="font-medium") + Muted(contact.email) + Badge(contact.category) + + Separator() + + Heading("Add Contact", level=3) + Form.from_model( + ContactModel, + on_submit=CallTool( + save_contact, + on_success=[ + SetState("contacts", RESULT), + ShowToast("Contact saved!", variant="success"), + ], + on_error=ShowToast(ERROR, variant="error"), + ), + ) + + Separator() + + Heading("Search", level=3) + with Form( + on_submit=CallTool( + search_contacts, + arguments={"query": STATE.query}, + on_success=SetState("contacts", RESULT), + ) + ): + Input(name="query", placeholder="Search by name or email...") + Button("Search") + + return PrefabApp(view=view, state={"contacts": list(_contacts)}) + + +mcp = FastMCP("Contacts Server", providers=[app]) + +if __name__ == "__main__": + mcp.run(transport="http") diff --git a/examples/apps/datatable_server.py b/examples/apps/datatable_server.py index 1f79c51dd..dc78b4984 100644 --- a/examples/apps/datatable_server.py +++ b/examples/apps/datatable_server.py @@ -1,144 +1,108 @@ -"""DataTable MCP App — interactive, sortable data views with Prefab. - -Demonstrates `fastmcp[apps]` with Prefab UI components: -- `app=True` for automatic renderer wiring -- `PrefabApp` with `DataTable` for rich tabular views -- Searchable, sortable, paginated tables -- Layout composition with `Column`, `Heading`, `Text`, and `Badge` - -Usage: - uv run python datatable_server.py # HTTP (port 8000) - uv run python datatable_server.py --stdio # stdio for MCP clients -""" - -from __future__ import annotations +from collections import Counter from prefab_ui.app import PrefabApp from prefab_ui.components import ( Badge, + Card, + CardContent, Column, - DataTable, - DataTableColumn, + Grid, Heading, - Muted, Row, + Separator, + Text, ) +from prefab_ui.components.charts import BarChart, ChartSeries, PieChart +from prefab_ui.components.data_table import DataTable, DataTableColumn from fastmcp import FastMCP mcp = FastMCP("Team Directory") -EMPLOYEES = [ +TEAM = [ { "name": "Alice Chen", "role": "Engineering", "level": "Senior", "location": "San Francisco", - "status": "active", - }, - { - "name": "Bob Martinez", - "role": "Design", - "level": "Lead", - "location": "New York", - "status": "active", }, + {"name": "Bob Martinez", "role": "Design", "level": "Lead", "location": "New York"}, { "name": "Carol Johnson", "role": "Engineering", "level": "Staff", "location": "London", - "status": "active", }, { "name": "David Kim", "role": "Product", "level": "Senior", "location": "San Francisco", - "status": "away", - }, - { - "name": "Eva Müller", - "role": "Engineering", - "level": "Mid", - "location": "Berlin", - "status": "active", }, + {"name": "Eva Müller", "role": "Engineering", "level": "Mid", "location": "Berlin"}, { "name": "Frank Okafor", "role": "Data Science", "level": "Senior", "location": "Lagos", - "status": "active", }, { "name": "Grace Liu", "role": "Engineering", "level": "Junior", "location": "Singapore", - "status": "active", - }, - { - "name": "Hassan Ali", - "role": "Design", - "level": "Senior", - "location": "Dubai", - "status": "away", - }, - { - "name": "Iris Tanaka", - "role": "Product", - "level": "Lead", - "location": "Tokyo", - "status": "active", - }, - { - "name": "James Wright", - "role": "Engineering", - "level": "Senior", - "location": "London", - "status": "inactive", - }, - { - "name": "Karen Petrov", - "role": "Data Science", - "level": "Lead", - "location": "Berlin", - "status": "active", - }, - { - "name": "Liam O'Brien", - "role": "Engineering", - "level": "Mid", - "location": "Dublin", - "status": "active", }, + {"name": "Hassan Ali", "role": "Design", "level": "Senior", "location": "Dubai"}, ] @mcp.tool(app=True) -def list_team(department: str | None = None) -> PrefabApp: - """Browse the team directory with sorting and search. +def team_directory(department: str | None = None) -> PrefabApp: + """Browse the team directory — sortable, searchable, with department breakdown.""" + rows = [p for p in TEAM if not department or p["role"] == department] - Args: - department: Filter by department (e.g. "Engineering", "Design"). - Leave empty to show everyone. - """ - if department: - rows = [e for e in EMPLOYEES if e["role"].lower() == department.lower()] - else: - rows = EMPLOYEES + dept_counts = Counter(p["role"] for p in rows) + chart_data = [{"department": k, "count": v} for k, v in dept_counts.items()] - active = sum(1 for e in rows if e["status"] == "active") + level_counts = Counter(p["level"] for p in rows) + level_data = [{"level": k, "count": v} for k, v in level_counts.items()] with Column(gap=6, css_class="p-6") as view: - with Column(gap=1): + with Row(gap=2, align="center"): Heading("Team Directory") - with Row(gap=2): - Muted(f"{len(rows)} members") - Muted(f"{active} active", css_class="text-success") - if department: - Badge(department, variant="outline") + Badge(f"{len(rows)} people", variant="secondary") + + with Grid(columns=2, gap=6): + with Card(): + with CardContent(): + Text( + "By Department", + css_class="text-sm font-medium text-muted-foreground mb-2", + ) + PieChart( + data=chart_data, + data_key="count", + name_key="department", + show_legend=True, + inner_radius=40, + height=200, + ) + + with Card(): + with CardContent(): + Text( + "By Level", + css_class="text-sm font-medium text-muted-foreground mb-2", + ) + BarChart( + data=level_data, + series=[ChartSeries(data_key="count", label="People")], + x_axis="level", + height=200, + horizontal=True, + ) + + Separator() DataTable( columns=[ @@ -146,19 +110,13 @@ def list_team(department: str | None = None) -> PrefabApp: DataTableColumn(key="role", header="Department", sortable=True), DataTableColumn(key="level", header="Level", sortable=True), DataTableColumn(key="location", header="Location", sortable=True), - DataTableColumn(key="status", header="Status", sortable=True), ], rows=rows, - searchable=True, + search=True, paginated=True, - page_size=10, ) - return PrefabApp( - title="Team Directory", - view=view, - state={"total": len(rows), "active": active}, - ) + return PrefabApp(view=view) if __name__ == "__main__": diff --git a/examples/apps/explorer/explorer_server.py b/examples/apps/explorer/explorer_server.py new file mode 100644 index 000000000..41896f211 --- /dev/null +++ b/examples/apps/explorer/explorer_server.py @@ -0,0 +1,590 @@ +"""Data explorer — a FastMCPApp example with tables, charts, and filtering. + +Demonstrates the full FastMCPApp stack: +- @app.ui() entry point with a tabbed data exploration interface +- @app.tool() backend tools for analysis, summaries, and filtering +- DataTable with sorting, search, and pagination +- BarChart and PieChart for data visualization +- Metric cards for summary statistics +- Select-driven filtering with CallTool +- State management with PrefabApp state dict and Rx() + +Usage: + uv run python explorer_server.py # HTTP (default) + uv run python explorer_server.py --stdio # stdio for MCP clients +""" + +from __future__ import annotations + +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.actions.mcp import CallTool +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Button, + Card, + CardContent, + Column, + DataTable, + DataTableColumn, + Grid, + Heading, + Metric, + Muted, + Row, + Select, + SelectOption, + Separator, + Tab, + Tabs, + Text, +) +from prefab_ui.components.charts import BarChart, ChartSeries, PieChart +from prefab_ui.rx import ERROR, RESULT, STATE, Rx + +from fastmcp import FastMCP, FastMCPApp + +# --------------------------------------------------------------------------- +# Sample data +# --------------------------------------------------------------------------- + +SALES_DATA: list[dict] = [ + { + "date": "2025-01-05", + "product": "Widget A", + "region": "North", + "amount": 1200, + "quantity": 10, + }, + { + "date": "2025-01-12", + "product": "Widget B", + "region": "South", + "amount": 850, + "quantity": 7, + }, + { + "date": "2025-01-18", + "product": "Gadget X", + "region": "East", + "amount": 2300, + "quantity": 15, + }, + { + "date": "2025-01-25", + "product": "Gadget Y", + "region": "West", + "amount": 1750, + "quantity": 12, + }, + { + "date": "2025-02-02", + "product": "Widget A", + "region": "East", + "amount": 1400, + "quantity": 11, + }, + { + "date": "2025-02-09", + "product": "Widget B", + "region": "North", + "amount": 920, + "quantity": 8, + }, + { + "date": "2025-02-15", + "product": "Gadget X", + "region": "South", + "amount": 2100, + "quantity": 14, + }, + { + "date": "2025-02-22", + "product": "Gadget Y", + "region": "West", + "amount": 1600, + "quantity": 11, + }, + { + "date": "2025-03-01", + "product": "Widget A", + "region": "South", + "amount": 1350, + "quantity": 10, + }, + { + "date": "2025-03-08", + "product": "Widget B", + "region": "West", + "amount": 780, + "quantity": 6, + }, + { + "date": "2025-03-14", + "product": "Gadget X", + "region": "North", + "amount": 2500, + "quantity": 17, + }, + { + "date": "2025-03-21", + "product": "Gadget Y", + "region": "East", + "amount": 1900, + "quantity": 13, + }, + { + "date": "2025-04-03", + "product": "Widget A", + "region": "West", + "amount": 1100, + "quantity": 9, + }, + { + "date": "2025-04-10", + "product": "Widget B", + "region": "East", + "amount": 960, + "quantity": 8, + }, + { + "date": "2025-04-17", + "product": "Gadget X", + "region": "South", + "amount": 2400, + "quantity": 16, + }, + { + "date": "2025-04-24", + "product": "Gadget Y", + "region": "North", + "amount": 1850, + "quantity": 12, + }, + { + "date": "2025-05-01", + "product": "Widget A", + "region": "North", + "amount": 1500, + "quantity": 12, + }, + { + "date": "2025-05-08", + "product": "Widget B", + "region": "South", + "amount": 890, + "quantity": 7, + }, + { + "date": "2025-05-15", + "product": "Gadget X", + "region": "West", + "amount": 2200, + "quantity": 15, + }, + { + "date": "2025-05-22", + "product": "Gadget Y", + "region": "East", + "amount": 1700, + "quantity": 11, + }, + { + "date": "2025-06-05", + "product": "Widget A", + "region": "East", + "amount": 1300, + "quantity": 10, + }, + { + "date": "2025-06-12", + "product": "Widget B", + "region": "North", + "amount": 1050, + "quantity": 9, + }, + { + "date": "2025-06-19", + "product": "Gadget X", + "region": "North", + "amount": 2600, + "quantity": 18, + }, + { + "date": "2025-06-26", + "product": "Gadget Y", + "region": "South", + "amount": 1650, + "quantity": 11, + }, + { + "date": "2025-07-03", + "product": "Widget A", + "region": "South", + "amount": 1450, + "quantity": 11, + }, + { + "date": "2025-07-10", + "product": "Widget B", + "region": "West", + "amount": 830, + "quantity": 7, + }, + { + "date": "2025-07-17", + "product": "Gadget X", + "region": "East", + "amount": 2350, + "quantity": 16, + }, + { + "date": "2025-07-24", + "product": "Gadget Y", + "region": "West", + "amount": 1800, + "quantity": 12, + }, + { + "date": "2025-08-01", + "product": "Widget A", + "region": "West", + "amount": 1250, + "quantity": 10, + }, + { + "date": "2025-08-08", + "product": "Widget B", + "region": "East", + "amount": 970, + "quantity": 8, + }, + { + "date": "2025-08-15", + "product": "Gadget X", + "region": "South", + "amount": 2450, + "quantity": 16, + }, + { + "date": "2025-08-22", + "product": "Gadget Y", + "region": "North", + "amount": 1950, + "quantity": 13, + }, +] + +REGIONS = ["All", "North", "South", "East", "West"] +PRODUCTS = ["All", "Widget A", "Widget B", "Gadget X", "Gadget Y"] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _filter_rows( + rows: list[dict], + region: str = "All", + product: str = "All", +) -> list[dict]: + filtered = rows + if region != "All": + filtered = [r for r in filtered if r["region"] == region] + if product != "All": + filtered = [r for r in filtered if r["product"] == product] + return filtered + + +def _compute_summary(rows: list[dict]) -> dict: + if not rows: + return { + "count": 0, + "total_amount": 0, + "avg_amount": 0, + "min_amount": 0, + "max_amount": 0, + "total_quantity": 0, + } + amounts = [r["amount"] for r in rows] + return { + "count": len(rows), + "total_amount": sum(amounts), + "avg_amount": round(sum(amounts) / len(amounts)), + "min_amount": min(amounts), + "max_amount": max(amounts), + "total_quantity": sum(r["quantity"] for r in rows), + } + + +def _aggregate_by(rows: list[dict], key: str) -> list[dict]: + totals: dict[str, int] = {} + for row in rows: + label = row[key] + totals[label] = totals.get(label, 0) + row["amount"] + return [{key: label, "amount": total} for label, total in sorted(totals.items())] + + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +app = FastMCPApp("Data Explorer") + + +@app.tool() +def analyze_data(region: str = "All", product: str = "All") -> dict: + """Filter and analyze sales data. Returns rows, summary, and chart data.""" + filtered = _filter_rows(SALES_DATA, region, product) + return { + "rows": filtered, + "summary": _compute_summary(filtered), + "by_region": _aggregate_by(filtered, "region"), + "by_product": _aggregate_by(filtered, "product"), + } + + +@app.tool(model=True) +def get_summary() -> dict: + """Return summary statistics for the full dataset.""" + return _compute_summary(SALES_DATA) + + +@app.tool() +def filter_data(region: str = "All", product: str = "All") -> list[dict]: + """Filter sales data by region and/or product.""" + return _filter_rows(SALES_DATA, region, product) + + +@app.ui() +def data_explorer() -> PrefabApp: + """Open the data explorer. Browse, filter, and visualize sales data.""" + + initial = analyze_data() + + with Column(gap=6, css_class="p-6") as view: + Heading("Sales Data Explorer") + Muted(f"{len(SALES_DATA)} records loaded") + + Separator() + + # ----- Filters ----- + with Row(gap=4, align="center"): + Text("Filters", css_class="font-semibold") + + with Select( + name="selected_region", + placeholder="Region", + value="All", + on_change=[ + SetState("loading", True), + CallTool( + analyze_data, + arguments={ + "region": STATE.selected_region, + "product": STATE.selected_product, + }, + on_success=[ + SetState("rows", RESULT.rows), + SetState("summary", RESULT.summary), + SetState("by_region", RESULT.by_region), + SetState("by_product", RESULT.by_product), + SetState("loading", False), + ShowToast("Data updated", variant="success"), + ], + on_error=[ + SetState("loading", False), + ShowToast(ERROR, variant="error"), + ], + ), + ], + ): + for region in REGIONS: + SelectOption(value=region, label=region) + + with Select( + name="selected_product", + placeholder="Product", + value="All", + on_change=[ + SetState("loading", True), + CallTool( + analyze_data, + arguments={ + "region": STATE.selected_region, + "product": STATE.selected_product, + }, + on_success=[ + SetState("rows", RESULT.rows), + SetState("summary", RESULT.summary), + SetState("by_region", RESULT.by_region), + SetState("by_product", RESULT.by_product), + SetState("loading", False), + ShowToast("Data updated", variant="success"), + ], + on_error=[ + SetState("loading", False), + ShowToast(ERROR, variant="error"), + ], + ), + ], + ): + for product in PRODUCTS: + SelectOption(value=product, label=product) + + Button( + Rx("loading").then("Loading...", "Reset"), + disabled=Rx("loading"), + on_click=[ + SetState("selected_region", "All"), + SetState("selected_product", "All"), + SetState("loading", True), + CallTool( + analyze_data, + arguments={"region": "All", "product": "All"}, + on_success=[ + SetState("rows", RESULT.rows), + SetState("summary", RESULT.summary), + SetState("by_region", RESULT.by_region), + SetState("by_product", RESULT.by_product), + SetState("loading", False), + ], + on_error=[ + SetState("loading", False), + ShowToast(ERROR, variant="error"), + ], + ), + ], + ) + + Separator() + + # ----- Tabs ----- + with Tabs(): + # ---- Summary ---- + with Tab("Summary"): + with Grid(columns=3, gap=4): + with Card(): + with CardContent(): + Metric( + label="Total Revenue", + value=Rx("summary.total_amount"), + ) + with Card(): + with CardContent(): + Metric( + label="Average Sale", + value=Rx("summary.avg_amount"), + ) + with Card(): + with CardContent(): + Metric( + label="Total Quantity", + value=Rx("summary.total_quantity"), + ) + + with Grid(columns=3, gap=4, css_class="mt-4"): + with Card(): + with CardContent(): + Metric( + label="Transactions", + value=Rx("summary.count"), + ) + with Card(): + with CardContent(): + Metric( + label="Min Sale", + value=Rx("summary.min_amount"), + ) + with Card(): + with CardContent(): + Metric( + label="Max Sale", + value=Rx("summary.max_amount"), + ) + + with Row(gap=2, css_class="mt-4"): + Badge(f"Region: {STATE.selected_region}") + Badge(f"Product: {STATE.selected_product}") + + # ---- Table ---- + with Tab("Table"): + DataTable( + columns=[ + DataTableColumn(key="date", header="Date", sortable=True), + DataTableColumn(key="product", header="Product", sortable=True), + DataTableColumn(key="region", header="Region", sortable=True), + DataTableColumn( + key="amount", header="Amount ($)", sortable=True + ), + DataTableColumn(key="quantity", header="Qty", sortable=True), + ], + rows="{{ rows }}", + search=True, + paginated=True, + page_size=10, + ) + + # ---- Charts ---- + with Tab("Charts"): + with Grid(columns=2, gap=6): + with Column(gap=2): + Heading("Revenue by Region", level=3) + BarChart( + data=Rx("by_region"), + series=[ChartSeries(data_key="amount", label="Revenue")], + x_axis="region", + show_legend=True, + ) + + with Column(gap=2): + Heading("Revenue by Product", level=3) + BarChart( + data=Rx("by_product"), + series=[ChartSeries(data_key="amount", label="Revenue")], + x_axis="product", + show_legend=True, + ) + + Separator(css_class="my-4") + + with Grid(columns=2, gap=6): + with Column(gap=2): + Heading("Region Breakdown", level=3) + PieChart( + data=Rx("by_region"), + data_key="amount", + name_key="region", + show_legend=True, + inner_radius=60, + ) + + with Column(gap=2): + Heading("Product Breakdown", level=3) + PieChart( + data=Rx("by_product"), + data_key="amount", + name_key="product", + show_legend=True, + inner_radius=60, + ) + + return PrefabApp( + view=view, + state={ + "rows": initial["rows"], + "summary": initial["summary"], + "by_region": initial["by_region"], + "by_product": initial["by_product"], + "selected_region": "All", + "selected_product": "All", + "loading": False, + }, + ) + + +mcp = FastMCP("Data Explorer", providers=[app]) + +if __name__ == "__main__": + mcp.run(transport="http") diff --git a/examples/apps/file_upload/file_upload_server.py b/examples/apps/file_upload/file_upload_server.py new file mode 100644 index 000000000..c567f7820 --- /dev/null +++ b/examples/apps/file_upload/file_upload_server.py @@ -0,0 +1,13 @@ +"""File upload — bypass the LLM context window to get files onto the server. + +Usage: + uv run python file_upload_server.py +""" + +from fastmcp import FastMCP +from fastmcp.apps.file_upload import FileUpload + +mcp = FastMCP("File Upload Server", providers=[FileUpload()]) + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/form/form_server.py b/examples/apps/form/form_server.py new file mode 100644 index 000000000..bfa2ab27e --- /dev/null +++ b/examples/apps/form/form_server.py @@ -0,0 +1,41 @@ +"""Form input — collect structured data from users via Pydantic models. + +Usage: + uv run python form_server.py +""" + +from typing import Literal + +from pydantic import BaseModel, Field + +from fastmcp import FastMCP +from fastmcp.apps.form import FormInput + + +class ShippingAddress(BaseModel): + name: str = Field(description="Full name") + street: str = Field(description="Street address") + city: str + state: str = Field(description="Two-letter state code") + zip_code: str = Field(description="5-digit ZIP") + + +class BugReport(BaseModel): + title: str = Field(description="Brief summary") + severity: Literal["low", "medium", "high", "critical"] + description: str = Field( + description="Detailed description", + json_schema_extra={"ui": {"type": "textarea"}}, + ) + + +mcp = FastMCP( + "Form Demo", + providers=[ + FormInput(model=ShippingAddress), + FormInput(model=BugReport), + ], +) + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/generative_ui.py b/examples/apps/generative_ui.py new file mode 100644 index 000000000..e03f6ed85 --- /dev/null +++ b/examples/apps/generative_ui.py @@ -0,0 +1,22 @@ +"""Generative UI — let the LLM build custom Prefab UIs on the fly. + +The GenerativeUI provider registers two tools: +- generate_prefab_ui: the LLM writes Prefab Python code, it runs in a sandbox, the result renders +- search_prefab_components: the LLM searches the Prefab component library + +The generative renderer supports streaming: as the LLM writes code into +the `code` argument, the host forwards partial arguments to the app via +ontoolinputpartial, and the user watches the UI build up in real time. + +Usage: + uv run python generative_ui.py +""" + +from fastmcp import FastMCP +from fastmcp.apps.generative import GenerativeUI + +mcp = FastMCP("Prefab Studio") +mcp.add_provider(GenerativeUI()) + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/greet_server.py b/examples/apps/greet_server.py new file mode 100644 index 000000000..bb3ad8321 --- /dev/null +++ b/examples/apps/greet_server.py @@ -0,0 +1,64 @@ +"""Minimal example demonstrating a @app=True tool with arguments. + +Usage: + uv run python greet_server.py +""" + +from __future__ import annotations + +from typing import Literal + +from prefab_ui.components import Badge, Column, Heading, Muted + +from fastmcp import FastMCP + +mcp = FastMCP("Greeter") + +GREETINGS: dict[str, str] = { + "English": "Hello", + "Spanish": "¡Hola", + "French": "Bonjour", + "Japanese": "こんにちは", + "Arabic": "مرحبا", +} + + +@mcp.tool(app=True) +def greet( + name: str, + language: Literal["English", "Spanish", "French", "Japanese", "Arabic"] = "English", +) -> Column: + """Greet someone in their language.""" + word = GREETINGS[language] + with Column(gap=3, css_class="p-8") as view: + Heading(f"{word}, {name}!") + Muted("Greeting rendered by FastMCP") + Badge(language) + return view + + +FAREWELLS: dict[str, str] = { + "English": "Goodbye", + "Spanish": "Adiós", + "French": "Au revoir", + "Japanese": "さようなら", + "Arabic": "مع السلامة", +} + + +@mcp.tool(app=True) +def farewell( + name: str, + language: Literal["English", "Spanish", "French", "Japanese", "Arabic"] = "English", +) -> Column: + """Say farewell in their language.""" + word = FAREWELLS[language] + with Column(gap=3, css_class="p-8") as view: + Heading(f"{word}, {name}!") + Muted("Farewell rendered by FastMCP") + Badge(language) + return view + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/inspector_demo.py b/examples/apps/inspector_demo.py new file mode 100644 index 000000000..2d5d244bc --- /dev/null +++ b/examples/apps/inspector_demo.py @@ -0,0 +1,120 @@ +"""Demo server for testing the dev apps MCP message inspector. + +Exercises tool calls, server notifications (ctx.log), and errors +so you can verify all message types appear in the inspector panel. + +Usage: + fastmcp dev apps examples/apps/inspector_demo.py +""" + +from __future__ import annotations + +from prefab_ui.actions import ShowToast +from prefab_ui.actions.mcp import CallTool, SendMessage, UpdateContext +from prefab_ui.components import ( + Badge, + Button, + Column, + Heading, + Muted, + Row, +) +from prefab_ui.rx import ERROR + +from fastmcp import FastMCP +from fastmcp.server.context import Context + +mcp = FastMCP("Inspector Demo") + + +@mcp.tool(app=True) +def demo() -> Column: + """A demo app that exercises various MCP message types.""" + with Column(gap=6, css_class="p-8 max-w-lg") as view: + Heading("Inspector Demo") + Muted("Click the buttons and watch the inspector panel on the right.") + + with Column(gap=3): + with Row(gap=2, align="center"): + Button( + "Call Tool", + variant="default", + on_click=CallTool( + "echo", + arguments={"message": "Hello from the inspector!"}, + on_success=ShowToast("Tool call succeeded", variant="success"), + on_error=ShowToast(ERROR, variant="error"), + ), + ) + Badge("tools/call + response", variant="secondary") + + with Row(gap=2, align="center"): + Button( + "Call with Logging", + variant="default", + on_click=CallTool( + "echo_with_logs", + arguments={"message": "Watch the notifications!"}, + on_success=ShowToast("Done (check logs)", variant="success"), + on_error=ShowToast(ERROR, variant="error"), + ), + ) + Badge("tools/call + notifications", variant="secondary") + + with Row(gap=2, align="center"): + Button( + "Trigger Error", + variant="destructive", + on_click=CallTool( + "fail", + arguments={}, + on_error=ShowToast(ERROR, variant="error"), + ), + ) + Badge("error response", variant="destructive") + + with Row(gap=2, align="center"): + Button( + "Update Context", + variant="outline", + on_click=[ + UpdateContext(content="Demo context from inspector"), + ShowToast("Context updated", variant="success"), + ], + ) + Badge("bridge: UpdateContext", variant="outline") + + with Row(gap=2, align="center"): + Button( + "Send Message", + variant="outline", + on_click=SendMessage("Tell me about this demo app"), + ) + Badge("bridge: SendMessage", variant="outline") + + return view + + +@mcp.tool() +def echo(message: str) -> str: + """Echo a message back.""" + return f"Echo: {message}" + + +@mcp.tool() +async def echo_with_logs(message: str, ctx: Context) -> str: + """Echo a message and emit log notifications.""" + await ctx.log(f"Processing: {message}", level="info") + await ctx.log("Step 1: validated input", level="debug") + await ctx.log("Step 2: generating response", level="debug") + return f"Logged echo: {message}" + + +@mcp.tool() +def fail() -> str: + """Always raises an error.""" + raise ValueError("This is a deliberate error for testing the inspector") + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/inventory/inventory_server.py b/examples/apps/inventory/inventory_server.py new file mode 100644 index 000000000..f00862006 --- /dev/null +++ b/examples/apps/inventory/inventory_server.py @@ -0,0 +1,445 @@ +"""Inventory tracker -- a FastMCPApp example with CRUD operations and rich UI. + +Demonstrates the full FastMCPApp stack: +- @app.ui() entry point that the model calls to open the app +- @app.tool() backend tools for add, update, delete, and search +- DataTable with sortable columns and built-in search +- Form.from_model() for auto-generated Pydantic model forms +- Tabs, Select filtering, ForEach results, and Toast notifications +- State management with PrefabApp state dict and Rx() + +Usage: + uv run python inventory_server.py # HTTP (default) + uv run python inventory_server.py --stdio # stdio for MCP clients +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.actions.mcp import CallTool +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Button, + Card, + CardContent, + Column, + DataTable, + DataTableColumn, + ForEach, + Form, + Grid, + Heading, + Input, + Muted, + Row, + Select, + SelectOption, + Separator, + Tab, + Tabs, + Text, +) +from prefab_ui.rx import ERROR, RESULT, STATE, Rx +from pydantic import BaseModel, Field + +from fastmcp import FastMCP, FastMCPApp + +# --------------------------------------------------------------------------- +# Data store +# --------------------------------------------------------------------------- + +_next_id = 11 + +_inventory: list[dict] = [ + { + "id": 1, + "name": "Wireless Mouse", + "category": "Electronics", + "quantity": 45, + "price": 29.99, + "last_updated": "2026-03-20", + }, + { + "id": 2, + "name": "Mechanical Keyboard", + "category": "Electronics", + "quantity": 32, + "price": 89.99, + "last_updated": "2026-03-19", + }, + { + "id": 3, + "name": "USB-C Hub", + "category": "Electronics", + "quantity": 18, + "price": 49.99, + "last_updated": "2026-03-18", + }, + { + "id": 4, + "name": "A4 Copy Paper (500 sheets)", + "category": "Office Supplies", + "quantity": 200, + "price": 8.50, + "last_updated": "2026-03-21", + }, + { + "id": 5, + "name": "Ballpoint Pens (box)", + "category": "Office Supplies", + "quantity": 150, + "price": 12.00, + "last_updated": "2026-03-20", + }, + { + "id": 6, + "name": "Sticky Notes (pack)", + "category": "Office Supplies", + "quantity": 85, + "price": 5.99, + "last_updated": "2026-03-17", + }, + { + "id": 7, + "name": "Standing Desk", + "category": "Furniture", + "quantity": 8, + "price": 499.00, + "last_updated": "2026-03-15", + }, + { + "id": 8, + "name": "Ergonomic Chair", + "category": "Furniture", + "quantity": 12, + "price": 349.00, + "last_updated": "2026-03-16", + }, + { + "id": 9, + "name": "Monitor Arm", + "category": "Furniture", + "quantity": 25, + "price": 79.99, + "last_updated": "2026-03-22", + }, + { + "id": 10, + "name": "Webcam HD", + "category": "Electronics", + "quantity": 60, + "price": 69.99, + "last_updated": "2026-03-21", + }, +] + +CATEGORIES = ["All", "Electronics", "Office Supplies", "Furniture"] + + +# --------------------------------------------------------------------------- +# Pydantic model for add-item form +# --------------------------------------------------------------------------- + + +class NewItem(BaseModel): + name: str = Field(title="Item Name", min_length=1) + category: Literal["Electronics", "Office Supplies", "Furniture"] = Field( + title="Category", + default="Electronics", + ) + quantity: int = Field(title="Quantity", ge=0, default=1) + price: float = Field(title="Unit Price ($)", ge=0.0, default=0.0) + + +# --------------------------------------------------------------------------- +# App and tools +# --------------------------------------------------------------------------- + +app = FastMCPApp("Inventory") + + +@app.tool() +def add_item(data: NewItem) -> list[dict]: + """Add a new item to inventory and return the full list.""" + global _next_id + item = { + "id": _next_id, + "name": data.name, + "category": data.category, + "quantity": data.quantity, + "price": data.price, + "last_updated": datetime.now().strftime("%Y-%m-%d"), + } + _next_id += 1 + _inventory.append(item) + return list(_inventory) + + +@app.tool() +def update_quantity(item_id: int, delta: int) -> list[dict]: + """Adjust an item's quantity by delta (+/-) and return the full list.""" + for item in _inventory: + if item["id"] == item_id: + new_qty = max(0, item["quantity"] + delta) + item["quantity"] = new_qty + item["last_updated"] = datetime.now().strftime("%Y-%m-%d") + break + return list(_inventory) + + +@app.tool() +def delete_item(item_id: int) -> list[dict]: + """Remove an item by ID and return the remaining inventory.""" + for i, item in enumerate(_inventory): + if item["id"] == item_id: + _inventory.pop(i) + break + return list(_inventory) + + +@app.tool() +def search_items(query: str) -> list[dict]: + """Search items by name (case-insensitive). Returns matching items.""" + q = query.lower() + return [item for item in _inventory if q in item["name"].lower()] + + +@app.tool() +def filter_by_category(category: str) -> list[dict]: + """Filter inventory by category. Pass 'All' to show everything.""" + if category == "All": + return list(_inventory) + return [item for item in _inventory if item["category"] == category] + + +# --------------------------------------------------------------------------- +# UI helpers +# --------------------------------------------------------------------------- + + +def _build_inventory_table() -> None: + """Render the main DataTable with all current items.""" + DataTable( + columns=[ + DataTableColumn(key="name", header="Name", sortable=True), + DataTableColumn(key="category", header="Category", sortable=True), + DataTableColumn(key="quantity", header="Qty", sortable=True), + DataTableColumn(key="price", header="Price ($)", sortable=True), + DataTableColumn(key="last_updated", header="Updated", sortable=True), + ], + rows=list(_inventory), + search=True, + paginated=True, + page_size=10, + ) + + +def _build_search_section() -> None: + """Render the search form with ForEach results.""" + Heading("Search Items", level=3) + Muted("Search by name across all inventory items.") + + with Form( + on_submit=CallTool( + search_items, + arguments={"query": STATE.query}, + on_success=SetState("search_results", RESULT), + ) + ): + Input(name="query", placeholder="Search by name...") + Button("Search") + + with ForEach("search_results") as result: + with Card(css_class="mb-2"): + with CardContent(): + with Row(gap=3, align="center"): + Text(result.name, css_class="font-medium") + Badge(result.category) + Text(result.quantity) + Muted("in stock") + + +def _build_add_form() -> None: + """Render the add-item form using Form.from_model().""" + Heading("Add New Item", level=3) + Muted("Fill out the form below to add a new item to inventory.") + + Form.from_model( + NewItem, + submit_label="Add Item", + on_submit=CallTool( + add_item, + on_success=[ + SetState("recent_additions", RESULT), + ShowToast("Item added!", variant="success"), + ], + on_error=ShowToast(ERROR, variant="error"), + ), + ) + + +def _build_actions_section() -> None: + """Render category filter, quantity adjustment, and delete controls.""" + + # Category filter + Heading("Filter by Category", level=3) + Muted("Select a category to see matching items.") + + with Form( + on_submit=CallTool( + filter_by_category, + arguments={"category": STATE.selected_category}, + on_success=SetState("filtered_items", RESULT), + ) + ): + with Select(name="selected_category", placeholder="Choose a category..."): + for cat in CATEGORIES: + SelectOption(cat, value=cat) + Button("Apply Filter") + + with ForEach("filtered_items") as item: + with Row(gap=3, align="center", css_class="py-1"): + Badge(item.id, variant="outline") + Text(item.name, css_class="font-medium") + Badge(item.category) + Muted(item.quantity) + + Separator() + + # Quantity adjustment + Heading("Adjust Quantity", level=3) + Muted("Enter an item ID and use the buttons to adjust stock levels.") + + Input(name="adjust_id", input_type="number", placeholder="Item ID (e.g. 1)") + + with Row(gap=2): + Button( + "- 1", + variant="outline", + on_click=CallTool( + update_quantity, + arguments={"item_id": STATE.adjust_id, "delta": -1}, + on_success=[ + SetState("filtered_items", RESULT), + ShowToast("Quantity decreased", variant="default"), + ], + on_error=ShowToast(ERROR, variant="error"), + ), + ) + Button( + "+ 1", + variant="outline", + on_click=CallTool( + update_quantity, + arguments={"item_id": STATE.adjust_id, "delta": 1}, + on_success=[ + SetState("filtered_items", RESULT), + ShowToast("Quantity increased", variant="default"), + ], + on_error=ShowToast(ERROR, variant="error"), + ), + ) + Button( + "+ 10", + on_click=CallTool( + update_quantity, + arguments={"item_id": STATE.adjust_id, "delta": 10}, + on_success=[ + SetState("filtered_items", RESULT), + ShowToast("Restocked +10", variant="success"), + ], + on_error=ShowToast(ERROR, variant="error"), + ), + ) + + Separator() + + # Delete + Heading("Delete Item", level=3) + Muted("Permanently remove an item by its ID.") + + with Form( + on_submit=CallTool( + delete_item, + arguments={"item_id": STATE.delete_id}, + on_success=[ + SetState("filtered_items", RESULT), + ShowToast("Item deleted", variant="warning"), + ], + on_error=ShowToast(ERROR, variant="error"), + ) + ): + Input(name="delete_id", input_type="number", placeholder="Item ID to delete") + Button("Delete", variant="destructive") + + +# --------------------------------------------------------------------------- +# Entry point UI +# --------------------------------------------------------------------------- + + +@app.ui() +def inventory_manager() -> PrefabApp: + """Open the inventory manager. The model calls this to launch the app.""" + with Column(gap=6, css_class="p-6") as view: + with Row(gap=3, align="center"): + Heading("Inventory Tracker") + Badge( + Rx("filtered_items.length"), + variant="secondary", + ) + Muted("items tracked") + + Separator() + + # Summary cards per category + with Grid(columns=3, gap=4): + for cat in ["Electronics", "Office Supplies", "Furniture"]: + count = sum(1 for it in _inventory if it["category"] == cat) + total_qty = sum( + it["quantity"] for it in _inventory if it["category"] == cat + ) + with Card(): + with CardContent(): + Text(cat, css_class="font-medium") + Muted(f"{count} items, {total_qty} units") + + with Tabs(): + with Tab("All Items"): + _build_inventory_table() + + with Tab("Search"): + _build_search_section() + + with Tab("Add Item"): + _build_add_form() + + with Tab("Actions"): + _build_actions_section() + + return PrefabApp( + view=view, + state={ + "search_results": [], + "filtered_items": list(_inventory), + "recent_additions": [], + "selected_category": "All", + "adjust_id": "", + "delete_id": "", + "query": "", + }, + ) + + +# --------------------------------------------------------------------------- +# Server +# --------------------------------------------------------------------------- + +mcp = FastMCP("Inventory Server", providers=[app]) + +if __name__ == "__main__": + mcp.run(transport="http") diff --git a/examples/apps/map/map_server.py b/examples/apps/map/map_server.py new file mode 100644 index 000000000..5bc868a14 --- /dev/null +++ b/examples/apps/map/map_server.py @@ -0,0 +1,164 @@ +"""Interactive Map — geocode addresses and render on an interactive map. + +Accepts plain addresses (or place names), geocodes them via +OpenStreetMap Nominatim, and renders an interactive Leaflet map. + +Usage: + fastmcp dev apps map_server.py +""" + +from __future__ import annotations + +from textwrap import dedent + +import httpx +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Card, + Column, + Embed, + Heading, + Muted, +) +from prefab_ui.components.data_table import DataTable, DataTableColumn + +from fastmcp import FastMCP + +mcp = FastMCP("Interactive Map") + +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" + + +def _geocode(query: str) -> dict | None: + """Geocode an address using OpenStreetMap Nominatim (free, no key).""" + resp = httpx.get( + NOMINATIM_URL, + params={"q": query, "format": "json", "limit": 1}, + headers={"User-Agent": "fastmcp-map-example/1.0"}, + timeout=10, + ) + results = resp.json() + if results: + r = results[0] + return { + "name": r.get("display_name", query).split(",")[0], + "address": query, + "lat": float(r["lat"]), + "lng": float(r["lon"]), + } + return None + + +def _build_map_html( + locations: list[dict], + zoom: int, +) -> str: + markers_js = "" + for loc in locations: + name = str(loc["name"]).replace("\\", "\\\\").replace("'", "\\'") + markers_js += ( + f"L.marker([{loc['lat']}, {loc['lng']}]).addTo(map).bindPopup('{name}');\n" + ) + + avg_lat = sum(loc["lat"] for loc in locations) / len(locations) + avg_lng = sum(loc["lng"] for loc in locations) / len(locations) + + return dedent(f"""\ + + + + + + + + + +
+ + + + """) + + +@mcp.tool(app=True) +def show_map( + locations: list[str] | None = None, + title: str = "Map", + zoom: int = 2, +) -> PrefabApp: + """Show locations on an interactive map. + + Accepts addresses, place names, or landmarks. Each location is + geocoded via OpenStreetMap and displayed as a marker on an + interactive Leaflet map. + + Args: + locations: List of addresses or place names. Defaults to + sample US landmarks if not provided. + title: Heading for the map. + zoom: Initial zoom level (1-18, higher = closer). + """ + if not locations: + locations = [ + "Statue of Liberty, New York", + "Golden Gate Bridge, San Francisco", + "Space Needle, Seattle", + "Willis Tower, Chicago", + "Gateway Arch, St. Louis", + ] + + geocoded = [] + failed = [] + for loc in locations: + result = _geocode(loc) + if result: + geocoded.append(result) + else: + failed.append(loc) + + with PrefabApp() as app: + with Column(gap=4, css_class="p-6"): + Heading(title) + Muted(f"{len(geocoded)} locations mapped") + if failed: + for f in failed: + Badge(f"Could not find: {f}", variant="destructive") + + if geocoded: + map_html = _build_map_html(geocoded, zoom) + with Card(): + Embed( + html=map_html, + width="100%", + height="500px", + sandbox="allow-scripts", + ) + DataTable( + columns=[ + DataTableColumn(key="name", header="Name", sortable=True), + DataTableColumn(key="address", header="Address", sortable=True), + DataTableColumn(key="lat", header="Latitude", sortable=True), + DataTableColumn(key="lng", header="Longitude", sortable=True), + ], + rows=geocoded, + search=True, + ) + + return app + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/patterns_server.py b/examples/apps/patterns_server.py index 9b5b8ac79..abf888c1c 100644 --- a/examples/apps/patterns_server.py +++ b/examples/apps/patterns_server.py @@ -18,13 +18,10 @@ from prefab_ui.components import ( Accordion, AccordionItem, Alert, - AreaChart, Badge, - BarChart, Button, Card, CardContent, - ChartSeries, Column, DataTable, DataTableColumn, @@ -35,7 +32,6 @@ from prefab_ui.components import ( If, Input, Muted, - PieChart, Progress, Row, Select, @@ -46,6 +42,8 @@ from prefab_ui.components import ( Text, Textarea, ) +from prefab_ui.components.charts import AreaChart, BarChart, ChartSeries, PieChart +from prefab_ui.rx import ERROR, Rx from fastmcp import FastMCP @@ -297,7 +295,7 @@ def employee_directory() -> PrefabApp: DataTableColumn(key="location", header="Office", sortable=True), ], rows=EMPLOYEES, - searchable=True, + search=True, paginated=True, page_size=15, ) @@ -316,11 +314,11 @@ def contact_form() -> PrefabApp: with Column(gap=6, css_class="p-6") as view: Heading("Contacts") - with ForEach("contacts"): + with ForEach("contacts") as item: with Row(gap=2, align="center"): - Text("{{ name }}", css_class="font-medium") - Muted("{{ email }}") - Badge("{{ category }}") + Text(item.name, css_class="font-medium") + Muted(item.email) + Badge(item.category) Separator() @@ -330,7 +328,7 @@ def contact_form() -> PrefabApp: "save_contact", result_key="contacts", on_success=ShowToast("Contact saved!", variant="success"), - on_error=ShowToast("{{ $error }}", variant="error"), + on_error=ShowToast(ERROR, variant="error"), ) ): Input(name="name", label="Full Name", required=True) @@ -411,9 +409,9 @@ def feature_flags() -> PrefabApp: Separator() - with If("{{ dark_mode }}"): + with If(Rx("dark_mode")): Alert(title="Dark mode enabled", description="UI will use dark theme.") - with If("{{ beta_features }}"): + with If(Rx("beta_features")): Alert( title="Beta features active", description="Experimental features are now visible.", @@ -451,10 +449,10 @@ def project_overview() -> PrefabApp: ) with Tab("Activity"): - with ForEach("activity"): + with ForEach("activity") as item: with Row(gap=2): - Muted("{{ timestamp }}") - Text("{{ message }}") + Muted(item.timestamp) + Text(item.message) return PrefabApp(view=view, state={"activity": PROJECT["activity"]}) diff --git a/examples/apps/quiz/quiz_server.py b/examples/apps/quiz/quiz_server.py new file mode 100644 index 000000000..7de6f08d7 --- /dev/null +++ b/examples/apps/quiz/quiz_server.py @@ -0,0 +1,258 @@ +"""Quiz / trivia app — a FastMCPApp example with multi-turn state. + +Demonstrates building state over a conversation: +- The LLM generates quiz questions and calls `take_quiz` to launch the UI +- The user answers via multiple-choice buttons (no forms) +- Each answer calls `submit_answer`, which returns correctness + updated score +- After the final question, a SendMessage pushes the score back to the LLM + +Usage: + uv run python quiz_server.py +""" + +from __future__ import annotations + +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.actions.mcp import CallTool, SendMessage +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Button, + Card, + Column, + Heading, + If, + Muted, + Progress, + Row, + Text, +) +from prefab_ui.rx import ERROR, RESULT, Rx + +from fastmcp import FastMCP, FastMCPApp + +app = FastMCPApp("Quiz") + +DEFAULT_QUESTIONS = [ + { + "question": "What is the capital of Australia?", + "options": ["Sydney", "Melbourne", "Canberra", "Perth"], + "correct": 2, + }, + { + "question": "Which planet has the most moons?", + "options": ["Jupiter", "Saturn", "Uranus", "Neptune"], + "correct": 1, + }, + { + "question": "What year did the Berlin Wall fall?", + "options": ["1987", "1989", "1991", "1993"], + "correct": 1, + }, + { + "question": "Which element has the chemical symbol 'Au'?", + "options": ["Silver", "Aluminum", "Gold", "Argon"], + "correct": 2, + }, + { + "question": "What is the deepest ocean?", + "options": ["Atlantic", "Indian", "Arctic", "Pacific"], + "correct": 3, + }, +] + + +# --------------------------------------------------------------------------- +# Backend tool — grade an answer and advance state +# --------------------------------------------------------------------------- + + +@app.tool() +def submit_answer( + question_index: int, + selected: int, + correct: int, + total_questions: int, + current_score: int, +) -> dict: + """Grade an answer and return the updated quiz state. + + Returns a dict with: + - is_correct: whether the selected answer matched the correct index + - new_score: the updated cumulative score + - answered_index: the question that was just answered + - finished: whether this was the last question + """ + is_correct = selected == correct + new_score = current_score + (1 if is_correct else 0) + finished = (question_index + 1) >= total_questions + return { + "is_correct": is_correct, + "new_score": new_score, + "answered_index": question_index, + "finished": finished, + } + + +# --------------------------------------------------------------------------- +# UI entry point — the LLM calls this with a topic and generated questions +# --------------------------------------------------------------------------- + + +@app.ui() +def take_quiz( + topic: str = "General Knowledge", + questions: list[dict] | None = None, +) -> PrefabApp: + """Launch a quiz UI. + + The LLM generates the questions and passes them in: + - topic: displayed as the heading (e.g. "World Capitals") + - questions: list of dicts, each with: + - "question": the question text + - "options": list of answer strings + - "correct": index of the correct option + + If no questions are provided, a built-in set is used. + """ + if questions is None: + questions = DEFAULT_QUESTIONS + total = len(questions) + score = Rx("score") + current_q = Rx("current_question") + answered = Rx("answered") + + with Column(gap=6, css_class="p-6 max-w-2xl") as view: + Heading(f"Quiz: {topic}") + + with Row(gap=3, align="center"): + Badge(f"{score}/{total} correct", variant="secondary") + Progress(value=current_q, max=total, size="sm") + + for i, q in enumerate(questions): + visible = current_q == i + options = q["options"] + correct_idx = q["correct"] + + with If(visible): + with Card(): + with Column(gap=4, css_class="p-4"): + Text( + f"Question {i + 1} of {total}", + css_class="text-sm font-medium text-muted-foreground", + ) + Heading(q["question"], level=3) + + with If(~answered): + with Column(gap=2): + for opt_idx, option in enumerate(options): + on_success_actions = [ + SetState("answered", True), + SetState( + "last_correct", + RESULT.is_correct, + ), + SetState("score", RESULT.new_score), + ] + is_last = (i + 1) >= total + if is_last: + on_success_actions.append( + SetState("finished", True), + ) + + Button( + option, + variant="outline", + css_class="w-full justify-start", + on_click=CallTool( + submit_answer, + arguments={ + "question_index": i, + "selected": opt_idx, + "correct": correct_idx, + "total_questions": total, + "current_score": str(score), + }, + on_success=on_success_actions, + on_error=ShowToast( + ERROR, + variant="error", + ), + ), + ) + + with If(answered): + with Column(gap=2): + for opt_idx, option in enumerate(options): + if opt_idx == correct_idx: + Button( + f"{option}", + variant="success", + css_class="w-full justify-start", + disabled=True, + ) + else: + Button( + option, + variant="ghost", + css_class="w-full justify-start opacity-50", + disabled=True, + ) + + with If(Rx("last_correct")): + Badge("Correct!", variant="success") + with If(~Rx("last_correct")): + Badge( + f"Incorrect — answer: {options[correct_idx]}", + variant="destructive", + ) + + with If(answered & ~Rx("finished")): + Button( + "Next Question", + variant="default", + on_click=[ + SetState("current_question", current_q + 1), + SetState("answered", False), + SetState("last_correct", False), + ], + ) + + with If(Rx("finished") & answered): + with Card(css_class="border-2 border-primary"): + with Column(gap=3, css_class="p-4 items-center text-center"): + Heading("Quiz Complete!", level=2) + Text( + f"{score}/{total} correct", + css_class="text-2xl font-bold", + ) + Progress( + value=score, + max=total, + variant="success", + size="lg", + ) + Muted("Click below to send your results to the conversation.") + Button( + "Send Results", + variant="default", + on_click=SendMessage( + f'Quiz complete! Topic: "{topic}" ' + f"— Final score: {score}/{total} correct.", + ), + ) + + initial_state = { + "score": 0, + "current_question": 0, + "answered": False, + "last_correct": False, + "finished": False, + } + return PrefabApp(view=view, state=initial_state) + + +mcp = FastMCP("Quiz Server", providers=[app]) + +if __name__ == "__main__": + mcp.run(transport="http") diff --git a/examples/apps/sales_dashboard/sales_dashboard_server.py b/examples/apps/sales_dashboard/sales_dashboard_server.py new file mode 100644 index 000000000..fe38c64bc --- /dev/null +++ b/examples/apps/sales_dashboard/sales_dashboard_server.py @@ -0,0 +1,233 @@ +from prefab_ui.components import ( + Card, + CardContent, + Column, + Grid, + Heading, + Metric, + Muted, + Row, + Separator, + Text, +) +from prefab_ui.components.charts import AreaChart, ChartSeries, PieChart +from prefab_ui.components.data_table import DataTable, DataTableColumn + +from fastmcp import FastMCP + +mcp = FastMCP("Sales Dashboard") + +MONTHLY_REVENUE = [ + {"month": "Jul", "new_business": 182_000, "expansion": 74_000, "renewal": 210_000}, + {"month": "Aug", "new_business": 195_000, "expansion": 81_000, "renewal": 215_000}, + {"month": "Sep", "new_business": 224_000, "expansion": 93_000, "renewal": 208_000}, + {"month": "Oct", "new_business": 210_000, "expansion": 88_000, "renewal": 222_000}, + {"month": "Nov", "new_business": 248_000, "expansion": 102_000, "renewal": 230_000}, + {"month": "Dec", "new_business": 271_000, "expansion": 115_000, "renewal": 238_000}, + {"month": "Jan", "new_business": 235_000, "expansion": 97_000, "renewal": 241_000}, + {"month": "Feb", "new_business": 262_000, "expansion": 108_000, "renewal": 245_000}, + {"month": "Mar", "new_business": 289_000, "expansion": 121_000, "renewal": 252_000}, + {"month": "Apr", "new_business": 305_000, "expansion": 134_000, "renewal": 258_000}, + {"month": "May", "new_business": 318_000, "expansion": 142_000, "renewal": 263_000}, + {"month": "Jun", "new_business": 342_000, "expansion": 156_000, "renewal": 270_000}, +] + +REVENUE_BY_SEGMENT = [ + {"segment": "Enterprise", "revenue": 3_840_000}, + {"segment": "Mid-Market", "revenue": 2_160_000}, + {"segment": "SMB", "revenue": 1_440_000}, + {"segment": "Startup", "revenue": 720_000}, +] + +RECENT_DEALS = [ + { + "company": "Meridian Health Systems", + "amount": "$485,000", + "stage": "Closed Won", + "rep": "Sarah Chen", + "close_date": "Jun 12, 2026", + }, + { + "company": "Atlas Financial Group", + "amount": "$372,000", + "stage": "Closed Won", + "rep": "Marcus Rivera", + "close_date": "Jun 10, 2026", + }, + { + "company": "Pinnacle Manufacturing", + "amount": "$298,000", + "stage": "Negotiation", + "rep": "Aisha Patel", + "close_date": "Jun 28, 2026", + }, + { + "company": "Crestview Logistics", + "amount": "$264,000", + "stage": "Proposal Sent", + "rep": "James O'Brien", + "close_date": "Jul 5, 2026", + }, + { + "company": "Northstar Retail", + "amount": "$215,000", + "stage": "Closed Won", + "rep": "Sarah Chen", + "close_date": "Jun 8, 2026", + }, + { + "company": "Ironclad Security", + "amount": "$189,000", + "stage": "Negotiation", + "rep": "Lena Kowalski", + "close_date": "Jul 1, 2026", + }, + { + "company": "Summit Analytics", + "amount": "$176,000", + "stage": "Closed Won", + "rep": "Marcus Rivera", + "close_date": "Jun 5, 2026", + }, + { + "company": "Brightpath Education", + "amount": "$142,000", + "stage": "Proposal Sent", + "rep": "Aisha Patel", + "close_date": "Jul 12, 2026", + }, + { + "company": "Vantage Media", + "amount": "$128,000", + "stage": "Closed Won", + "rep": "Lena Kowalski", + "close_date": "Jun 3, 2026", + }, + { + "company": "Redwood Hospitality", + "amount": "$97,000", + "stage": "Discovery", + "rep": "James O'Brien", + "close_date": "Jul 20, 2026", + }, +] + + +@mcp.tool(app=True) +def sales_dashboard() -> Column: + """Company sales dashboard with KPIs, revenue trends, segment breakdown, and recent deals.""" + total_revenue = sum( + row["new_business"] + row["expansion"] + row["renewal"] + for row in MONTHLY_REVENUE + ) + current_quarter = sum( + row["new_business"] + row["expansion"] + row["renewal"] + for row in MONTHLY_REVENUE[-3:] + ) + prior_quarter = sum( + row["new_business"] + row["expansion"] + row["renewal"] + for row in MONTHLY_REVENUE[-6:-3] + ) + growth_pct = (current_quarter - prior_quarter) / prior_quarter * 100 + + with Column(gap=6, css_class="p-6") as view: + with Row(gap=2, align="center"): + Heading("Sales Dashboard") + Muted("FY2026 | Last updated Jun 15, 2026") + + with Grid(columns=4, gap=4): + with Card(): + with CardContent(): + Metric( + label="Total Revenue", + value=f"${total_revenue / 1_000_000:.1f}M", + delta="+18.2% YoY", + trend="up", + ) + + with Card(): + with CardContent(): + Metric( + label="Quarterly Growth", + value=f"{growth_pct:.1f}%", + delta="+3.8pp vs prior", + trend="up", + ) + + with Card(): + with CardContent(): + Metric( + label="Active Customers", + value="1,847", + delta="+124 this quarter", + trend="up", + ) + + with Card(): + with CardContent(): + Metric( + label="Avg Deal Size", + value="$236K", + delta="+12% vs H1", + trend="up", + ) + + with Grid(columns=3, gap=6): + with Card(css_class="col-span-2"): + with CardContent(): + Text( + "Monthly Revenue", + css_class="text-sm font-medium text-muted-foreground mb-2", + ) + AreaChart( + data=MONTHLY_REVENUE, + series=[ + ChartSeries(data_key="new_business", label="New Business"), + ChartSeries(data_key="expansion", label="Expansion"), + ChartSeries(data_key="renewal", label="Renewal"), + ], + x_axis="month", + stacked=True, + curve="smooth", + show_legend=True, + height=280, + y_axis_format="compact", + ) + + with Card(): + with CardContent(): + Text( + "Revenue by Segment", + css_class="text-sm font-medium text-muted-foreground mb-2", + ) + PieChart( + data=REVENUE_BY_SEGMENT, + data_key="revenue", + name_key="segment", + show_legend=True, + inner_radius=50, + height=280, + ) + + Separator() + + Text("Recent Deals", css_class="text-lg font-semibold") + + DataTable( + columns=[ + DataTableColumn(key="company", header="Company", sortable=True), + DataTableColumn(key="amount", header="Amount", sortable=True), + DataTableColumn(key="stage", header="Stage", sortable=True), + DataTableColumn(key="rep", header="Sales Rep", sortable=True), + DataTableColumn(key="close_date", header="Close Date", sortable=True), + ], + rows=RECENT_DEALS, + search=True, + paginated=True, + ) + + return view + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/showcase_server.py b/examples/apps/showcase_server.py new file mode 100644 index 000000000..4368a7f9c --- /dev/null +++ b/examples/apps/showcase_server.py @@ -0,0 +1,345 @@ +# ruff: noqa: F405 +"""Component showcase — demonstrates the breadth of Prefab UI components. + +Usage: + uv run python showcase_server.py +""" + +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.app import PrefabApp +from prefab_ui.components import * # noqa: F403, F405 +from prefab_ui.components.charts import * # noqa: F403, F405 +from prefab_ui.components.control_flow import Else, If + +from fastmcp import FastMCP + +mcp = FastMCP("Showcase") + + +@mcp.tool(app=True) +def showcase() -> PrefabApp: + """Prefab UI component showcase.""" + with Grid(columns={"default": 1, "md": 2, "lg": 4}, gap=4, css_class="p-4") as view: + # ── Col 1 ───────────────────────────────────────────────────── + with Column(gap=4): + with Card(): + with CardHeader(): + CardTitle("Register Towel") + CardDescription("The most important item in the galaxy") + with CardContent(): + with Column(gap=3): + owner_input = Input(placeholder="Owner name...", name="owner") + with Combobox( + placeholder="Type...", search_placeholder="Search types..." + ): + ComboboxOption("Bath", value="bath") + ComboboxOption("Beach", value="beach") + ComboboxOption("Interstellar", value="interstellar") + ComboboxOption("Microfiber", value="micro") + DatePicker(placeholder="Registration date") + with CardFooter(): + with Row(gap=2): + with Dialog( + title="Towel Registered!", + description="Your towel has been added to the galactic registry.", + ): + Button("Register") + with If("{{ owner }}"): + Text( + f"Thanks, {owner_input.rx}. Don't forget to bring it." + ) + with Else(): + Text("Anonymous, I see? Don't forget to bring it.") + Button("Cancel", variant="outline") + with Card(): + with CardContent(): + with Row(gap=2, align="center"): + Loader(variant="dots", size="sm") + Muted("Marvin is thinking...") + + with Card(): + with CardHeader(): + CardTitle("Ship Status") + with CardContent(): + with Column(gap=3): + with Row(align="center", css_class="justify-between"): + Text("heart-of-gold") + with HoverCard(open_delay=0, close_delay=200): + Badge("In Orbit", variant="default") + with Column(gap=2): + Text("heart-of-gold") + Muted("Deployed 2h ago") + Progress(value=100, max=100, variant="success") + Progress(value=100, max=100, indicator_class="bg-yellow-400") + with Row(align="center", css_class="justify-between"): + Text("vogon-poetry") + with Tooltip("64% — ETA 12 min", delay=0): + with Badge(variant="secondary"): + Loader(size="sm") + Text("Deploying") + Progress(value=64, max=100) + with Row(align="center", css_class="justify-between"): + Text("deep-thought") + with Tooltip( + "Computing... 7.5 million years remaining", delay=0 + ): + with Badge(variant="outline"): + Loader(size="sm", variant="ios") + Text("Soon...") + Progress(value=12, max=100) + with Card(): + with CardHeader(): + CardTitle("Planet Ratings") + with CardContent(): + RadarChart( + data=[ + {"axis": "Views", "earth": 30, "mag": 95}, + {"axis": "Fjords", "earth": 65, "mag": 100}, + {"axis": "Pubs", "earth": 90, "mag": 10}, + {"axis": "Mice", "earth": 40, "mag": 85}, + {"axis": "Tea", "earth": 95, "mag": 15}, + {"axis": "Safety", "earth": 45, "mag": 70}, + ], + series=[ + ChartSeries(dataKey="earth", label="Earth"), + ChartSeries(dataKey="mag", label="Magrathea"), + ], + axis_key="axis", + height=200, + show_legend=True, + show_tooltip=True, + ) + + # ── Col 2 ───────────────────────────────────────────────────── + with Column(gap=4): + with Card(): + with CardHeader(): + CardTitle("Survival Odds") + with CardContent(css_class="w-fit mx-auto"): + Ring( + value=42, + label="42%", + variant="info", + size="lg", + thickness=12, + indicator_class="group-hover:drop-shadow-[0_0_24px_rgba(59,130,246,0.9)]", + ) + with Card(): + with CardHeader(): + with Row(gap=2, align="center"): + CardTitle("Improbability Drive") + Loader(variant="pulse", size="sm", css_class="text-blue-500") + with CardContent(): + with Column(gap=2): + Slider(min=0, max=100, value=42, name="improbability") + with Row(align="center", css_class="justify-between"): + Muted("Probable") + Muted("Infinite") + with Alert(variant="success", icon="circle-check"): + AlertTitle("Don't Panic") + AlertDescription("Normality achieved.") + with Card(): + with CardHeader(): + CardTitle("Prefect Horizon Config") + with CardContent(): + with Column(gap=3): + Switch(label="Auto-scale agents", value=True, name="autoscale") + Separator() + Switch(label="Code Mode", value=True, name="code_mode") + Separator() + Switch(label="Tool call caching", value=False, name="cache") + with CardFooter(): + Button("Save Preferences", on_click=ShowToast("Preferences saved!")) + with Card(): + with CardHeader(): + CardTitle("Travel Class") + with CardContent(): + with RadioGroup(name="travel_class"): + Radio(option="economy", label="Economy") + Radio(option="business", label="Business Class") + Radio( + option="improbability", + label="Infinite Improbability", + value=True, + ) + + # ── Cols 3–4 ────────────────────────────────────────────────── + with GridItem(css_class="md:col-span-2"): + with Column(gap=4): + with Grid(columns=2, gap=4, css_class="h-32"): + with Card(): + with CardHeader(): + CardTitle("Context Window") + with CardContent(): + with Column(gap=6, justify="center", css_class="h-full"): + with Row(align="center", css_class="justify-between"): + Text("45% used") + Muted("90k / 200k tokens") + with Tooltip("Auto-compact buffer: 12%", delay=0): + Progress(value=45, max=100) + with Card(css_class="pb-0 gap-0"): + with CardContent(): + Metric( + label="Fjords designed", + value="1,847", + delta="+3 coastlines", + ) + Sparkline( + data=[ + 820, + 950, + 1100, + 980, + 1250, + 1400, + 1350, + 1500, + 1680, + 1847, + ], + variant="success", + fill=True, + css_class="h-16", + ) + with Card(): + with CardHeader(): + CardTitle("Towel Incidents") + with CardContent(): + BarChart( + data=[ + {"month": "Jan", "lost": 8, "found": 5}, + {"month": "Feb", "lost": 24, "found": 15}, + {"month": "Mar", "lost": 12, "found": 28}, + {"month": "Apr", "lost": 35, "found": 19}, + {"month": "May", "lost": 18, "found": 38}, + {"month": "Jun", "lost": 42, "found": 30}, + ], + series=[ + ChartSeries(dataKey="lost", label="Lost"), + ChartSeries(dataKey="found", label="Found"), + ], + x_axis="month", + height=200, + bar_radius=4, + show_legend=True, + show_tooltip=True, + show_grid=True, + ) + + with Grid(columns=2, gap=4): + with Column(gap=4): + with Card(): + with CardContent(): + with Column(gap=2): + Checkbox(label="Towel packed", value=True) + Checkbox(label="Guide charged", value=True) + Checkbox(label="Babel fish inserted", value=False) + with If("{{ !pressed }}"): + Button( + "This is probably the best button to press.", + variant="success", + on_click=SetState("pressed", True), + ) + with Else(): + Button( + "Please do not press this button again.", + variant="destructive", + on_click=SetState("pressed", False), + ) + with Card(): + with CardHeader(): + CardTitle("Marvin's Mood") + with CardContent(): + with Column(gap=3): + P("How's life?") + with Column(gap=2): + Button( + "Meh", + on_click=ShowToast( + "Noted. Enthusiasm levels nominal." + ), + ) + Button( + "Depressed", + variant="info", + on_click=ShowToast( + "I think you ought to know I'm feeling very depressed." + ), + ) + Button( + "Don't talk to me about life", + variant="warning", + on_click=ShowToast( + "Brain the size of a planet and they ask me to pick up a piece of paper." + ), + ) + + with Column(gap=4): + with Alert(variant="destructive", icon="triangle-alert"): + AlertTitle("Beware of the Leopard") + with Card(): + with CardContent(): + DataTable( + columns=[ + DataTableColumn( + key="crew", header="Crew", sortable=True + ), + DataTableColumn( + key="species", + header="Species", + sortable=True, + ), + DataTableColumn( + key="towel", header="Towel?", sortable=True + ), + DataTableColumn( + key="status", header="Status", sortable=True + ), + ], + rows=[ + { + "crew": "Arthur Dent", + "species": "Human", + "towel": "Yes", + "status": "Confused", + }, + { + "crew": "Ford Prefect", + "species": "Betelgeusian", + "towel": "Always", + "status": "Drinking", + }, + { + "crew": "Zaphod", + "species": "Betelgeusian", + "towel": "Lost it", + "status": "Presidential", + }, + { + "crew": "Trillian", + "species": "Human", + "towel": "Yes", + "status": "Navigating", + }, + { + "crew": "Marvin", + "species": "Android", + "towel": "No point", + "status": "Depressed", + }, + { + "crew": "Slartibartfast", + "species": "Magrathean", + "towel": "Somewhere", + "status": "Designing", + }, + ], + search=True, + paginated=False, + ) + + return PrefabApp(view=view, state={"pressed": False, "improbability": 42}) + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/system_monitor/system_monitor_server.py b/examples/apps/system_monitor/system_monitor_server.py new file mode 100644 index 000000000..7105a3697 --- /dev/null +++ b/examples/apps/system_monitor/system_monitor_server.py @@ -0,0 +1,195 @@ +"""System monitor — live CPU, memory, and disk stats from the host machine. + +Auto-refreshes every 3 seconds via SetInterval + CallTool. + +Requires psutil: pip install psutil + +Usage: + fastmcp dev apps system_monitor_server.py +""" + +import platform +import time +from datetime import datetime + +import psutil +from prefab_ui.actions import SetInterval, SetState +from prefab_ui.actions.mcp import CallTool +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Card, + CardContent, + CardHeader, + Column, + Grid, + Heading, + Metric, + Muted, + Progress, + Row, + Select, + SelectOption, + Small, + Text, +) +from prefab_ui.components.charts import AreaChart, ChartSeries +from prefab_ui.components.control_flow import ForEach +from prefab_ui.rx import RESULT, STATE, Rx + +from fastmcp import FastMCP +from fastmcp.apps.app import FastMCPApp + +app = FastMCPApp("Monitor") + +_history: list[dict] = [] + + +def _collect_stats() -> dict: + """Collect a full snapshot of system stats.""" + cpu = psutil.cpu_percent(interval=0.1) + mem = psutil.virtual_memory() + disk = psutil.disk_usage("/") + + now = datetime.now().strftime("%H:%M:%S") + _history.append({"time": now, "cpu": cpu, "memory": mem.percent}) + if len(_history) > 100: + del _history[: len(_history) - 100] + + top_procs = [] + for p in sorted( + psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]), + key=lambda p: p.info.get("cpu_percent") or 0, + reverse=True, + )[:6]: + info = p.info + top_procs.append( + { + "pid": info.get("pid") or 0, + "name": info.get("name") or "unknown", + "cpu": f"{(info.get('cpu_percent') or 0):.1f}%", + "memory": f"{(info.get('memory_percent') or 0):.1f}%", + } + ) + + return { + "cpu": cpu, + "mem_pct": mem.percent, + "mem_used": mem.used // (1024**3), + "mem_total": mem.total // (1024**3), + "disk_pct": disk.percent, + "disk_used": disk.used // (1024**3), + "disk_total": disk.total // (1024**3), + "uptime": _format_uptime(), + "cores": psutil.cpu_count(), + "platform": f"{platform.system()} {platform.machine()}", + "hostname": platform.node(), + "healthy": cpu < 80 and mem.percent < 90, + "history": list(_history), + "top_procs": top_procs, + } + + +def _format_uptime() -> str: + elapsed = int(time.time() - psutil.boot_time()) + days, remainder = divmod(elapsed, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, _ = divmod(remainder, 60) + if days > 0: + return f"{days}d {hours}h {minutes}m" + return f"{hours}h {minutes}m" + + +@app.tool() +def refresh() -> dict: + """Collect fresh system stats.""" + return _collect_stats() + + +@app.ui() +def system_dashboard() -> PrefabApp: + """Live system dashboard with auto-refresh.""" + initial = _collect_stats() + + with PrefabApp(state={"stats": initial, "interval": "500"}) as ui: + with Column( + gap=6, + css_class="p-6", + on_mount=SetInterval( + duration=Rx("interval"), + on_tick=CallTool( + "refresh", + on_success=SetState("stats", RESULT), + ), + ), + ): + with Row(gap=3, align="center"): + Heading("System Monitor") + Badge(STATE.stats.hostname, variant="outline") + with Select(name="interval", css_class="w-32"): + SelectOption("0.5s", value="500") + SelectOption("1s", value="1000") + SelectOption("5s", value="5000") + + with Grid(columns=4, gap=4): + with Card(): + with CardContent(): + Metric(label="CPU", value=f"{STATE.stats.cpu}%") + Progress(value=STATE.stats.cpu) + + with Card(): + with CardContent(): + Metric(label="Memory", value=f"{STATE.stats.mem_pct}%") + Progress(value=STATE.stats.mem_pct) + Muted(f"{STATE.stats.mem_used}GB / {STATE.stats.mem_total}GB") + + with Card(): + with CardContent(): + Metric(label="Disk", value=f"{STATE.stats.disk_pct}%") + Progress(value=STATE.stats.disk_pct) + Muted(f"{STATE.stats.disk_used}GB / {STATE.stats.disk_total}GB") + + with Card(): + with CardContent(): + Metric(label="Uptime", value=STATE.stats.uptime) + Muted(f"{STATE.stats.cores} cores") + + with Grid(columns=[2, 1], gap=4): + with Card(): + with CardHeader(): + Text("CPU & Memory", css_class="text-sm font-medium") + with CardContent(): + AreaChart( + data=STATE.stats.history, + series=[ + ChartSeries(data_key="cpu", label="CPU %"), + ChartSeries(data_key="memory", label="Memory %"), + ], + x_axis="time", + curve="smooth", + show_legend=True, + height=220, + animate=False, + ) + + with Card(): + with CardHeader(): + Text("Top Processes", css_class="text-sm font-medium") + with CardContent(): + with Column(gap=2): + with ForEach("stats.top_procs") as proc: + with Row(justify="between", align="center"): + with Column(gap=0): + Small(proc.name) + Muted(proc.pid) + with Row(gap=2): + Badge(proc.cpu, variant="outline") + Badge(proc.memory, variant="outline") + + return ui + + +mcp = FastMCP("System Monitor", providers=[app]) + +if __name__ == "__main__": + mcp.run() diff --git a/examples/auth/clerk_oauth/README.md b/examples/auth/clerk_oauth/README.md new file mode 100644 index 000000000..84d2b44b1 --- /dev/null +++ b/examples/auth/clerk_oauth/README.md @@ -0,0 +1,36 @@ +# Clerk OAuth Example + +Demonstrates FastMCP server protection with Clerk OAuth. + +## Setup + +1. Create a Clerk OAuth Application: + - Go to [Clerk Dashboard](https://dashboard.clerk.com/) + - Create or select an application + - Go to Developers > OAuth Applications + - Create an OAuth application + - Add Authorized redirect URI: `http://localhost:8000/auth/callback` + - Copy the Client ID and Client Secret + - Note your instance domain (e.g., `saving-primate-16.clerk.accounts.dev`) + +2. Set environment variables: + + ```bash + export FASTMCP_SERVER_AUTH_CLERK_DOMAIN="your-instance.clerk.accounts.dev" + export FASTMCP_SERVER_AUTH_CLERK_CLIENT_ID="your-clerk-client-id" + export FASTMCP_SERVER_AUTH_CLERK_CLIENT_SECRET="your-clerk-client-secret" + ``` + +3. Run the server: + + ```bash + python server.py + ``` + +4. In another terminal, run the client: + + ```bash + python client.py + ``` + +The client will open your browser for Clerk authentication. diff --git a/examples/auth/clerk_oauth/client.py b/examples/auth/clerk_oauth/client.py new file mode 100644 index 000000000..d9d44c9d6 --- /dev/null +++ b/examples/auth/clerk_oauth/client.py @@ -0,0 +1,33 @@ +"""OAuth client example for connecting to a Clerk-protected FastMCP server. + +This example demonstrates how to connect to an OAuth-protected FastMCP server +using Clerk as the identity provider. + +To run: + python client.py +""" + +import asyncio + +from fastmcp.client import Client + +SERVER_URL = "http://127.0.0.1:8000/mcp" + + +async def main(): + try: + async with Client(SERVER_URL, auth="oauth") as client: + assert await client.ping() + print("✅ Successfully authenticated!") + + tools = await client.list_tools() + print(f"🔧 Available tools ({len(tools)}):") + for tool in tools: + print(f" - {tool.name}: {tool.description}") + except Exception as e: + print(f"❌ Authentication failed: {e}") + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/auth/clerk_oauth/server.py b/examples/auth/clerk_oauth/server.py new file mode 100644 index 000000000..74b1e4687 --- /dev/null +++ b/examples/auth/clerk_oauth/server.py @@ -0,0 +1,40 @@ +"""Clerk OAuth server example for FastMCP. + +This example demonstrates how to protect a FastMCP server with Clerk OAuth. + +Required environment variables: +- FASTMCP_SERVER_AUTH_CLERK_DOMAIN: Your Clerk instance domain + (e.g., "saving-primate-16.clerk.accounts.dev") +- FASTMCP_SERVER_AUTH_CLERK_CLIENT_ID: Your Clerk OAuth client ID +- FASTMCP_SERVER_AUTH_CLERK_CLIENT_SECRET: Your Clerk OAuth client secret + +To run: + python server.py +""" + +import os + +from fastmcp import FastMCP +from fastmcp.server.auth.providers.clerk import ClerkProvider + +auth = ClerkProvider( + domain=os.getenv("FASTMCP_SERVER_AUTH_CLERK_DOMAIN") or "", + client_id=os.getenv("FASTMCP_SERVER_AUTH_CLERK_CLIENT_ID") or "", + client_secret=os.getenv("FASTMCP_SERVER_AUTH_CLERK_CLIENT_SECRET") or "", + base_url="http://localhost:8000", + # redirect_path="/auth/callback", # Default path - change if using a different callback URL + # Optional: specify required scopes (defaults to ["openid", "email", "profile"]) + # required_scopes=["openid", "email", "profile", "public_metadata"], +) + +mcp = FastMCP("Clerk OAuth Example Server", auth=auth) + + +@mcp.tool +def echo(message: str) -> str: + """Echo the provided message.""" + return message + + +if __name__ == "__main__": + mcp.run(transport="http", port=8000) diff --git a/examples/testing_demo/uv.lock b/examples/testing_demo/uv.lock index 8f07579f9..74be17426 100644 --- a/examples/testing_demo/uv.lock +++ b/examples/testing_demo/uv.lock @@ -2,6 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.10" +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +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 = "annotated-types" version = "0.7.0" @@ -13,26 +25,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.11.0" +version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, -] - -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] [[package]] @@ -46,14 +48,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.6" +version = "1.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] [[package]] @@ -76,29 +78,58 @@ wheels = [ [[package]] name = "beartype" -version = "0.22.5" +version = "0.22.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/09/9003e5662691056e0e8b2e6f57c799e71875fac0be0e785d8cb11557cd2a/beartype-0.22.5.tar.gz", hash = "sha256:516a9096cc77103c96153474fa35c3ebcd9d36bd2ec8d0e3a43307ced0fa6341", size = 1586256, upload-time = "2025-11-01T05:49:20.771Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/f6/073d19f7b571c08327fbba3f8e011578da67ab62a11f98911274ff80653f/beartype-0.22.5-py3-none-any.whl", hash = "sha256:d9743dd7cd6d193696eaa1e025f8a70fb09761c154675679ff236e61952dfba0", size = 1321700, upload-time = "2025-11-01T05:49:18.436Z" }, + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] [[package]] name = "cachetools" -version = "6.2.1" +version = "7.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, + { 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" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] [[package]] name = "certifi" -version = "2025.10.5" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, + { 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" }, ] [[package]] @@ -183,114 +214,16 @@ wheels = [ { 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" }, ] -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - [[package]] name = "click" -version = "8.3.0" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, -] - -[[package]] -name = "cloudpickle" -version = "3.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, + { 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" }, ] [[package]] @@ -304,67 +237,67 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, - { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, + { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, ] [[package]] name = "cyclopts" -version = "4.2.1" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -374,27 +307,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/8a/51/a67b17fac2530d22216a335bd10f48631412dd824013ea559ec236668f76/cyclopts-4.2.1.tar.gz", hash = "sha256:49bb4c35644e7a9658f706ade4cf1a9958834b2dca4425e2fafecf8a0537fac7", size = 148693, upload-time = "2025-10-31T14:30:58.681Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/e7/3e26855c046ac527cf94d890f6698e703980337f22ea7097e02b35b910f9/cyclopts-4.10.0.tar.gz", hash = "sha256:0ae04a53274e200ef3477c8b54de63b019bc6cd0162d75c718bf40c9c3fb5268", size = 166394, upload-time = "2026-03-14T14:09:31.043Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/1d/2b313e157c9c7bba319e42f464d15073d32a81ac4827bdc5b7de38832b3e/cyclopts-4.2.1-py3-none-any.whl", hash = "sha256:17a801faa814988b0307385ef8aaeb6b14b4d64473015a2d66bde9ea13f14d9c", size = 184333, upload-time = "2025-10-31T14:30:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/06/06/d68a5d5d292c2ad2bc6a02e5ca2cb1bb9c15e941ab02f004a06a342d7f0f/cyclopts-4.10.0-py3-none-any.whl", hash = "sha256:50f333382a60df8d40ec14aa2e627316b361c4f478598ada1f4169d959bf9ea7", size = 204097, upload-time = "2026-03-14T14:09:32.504Z" }, ] [[package]] name = "dirty-equals" -version = "0.10.0" +version = "0.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/69/f8a63f97166565dbf01e6a3fdf4665313719a6781125f105e4ffde82c5cd/dirty_equals-0.10.0.tar.gz", hash = "sha256:623d7a07c5ba437f1a834c6246d1e3eb97238ca70331c61a499d9aabd757b899", size = 125778, upload-time = "2025-09-19T16:05:31.371Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/1d/c5913ac9d6615515a00f4bdc71356d302437cb74ff2e9aaccd3c14493b78/dirty_equals-0.11.tar.gz", hash = "sha256:f4ac74ee88f2d11e2fa0f65eb30ee4f07105c5f86f4dc92b09eb1138775027c3", size = 128067, upload-time = "2025-11-17T01:51:24.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/87/0fc6e51f9db3a3b3de88fb0c9cf6414d4572d565f4ba4d166023cbd4354d/dirty_equals-0.10.0-py3-none-any.whl", hash = "sha256:bbf4a4eaafd56e371dafe2edf2265315ebd71a441b142ed801511aa33e4c3438", size = 28014, upload-time = "2025-09-19T16:05:29.953Z" }, -] - -[[package]] -name = "diskcache" -version = "5.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8d/dbff05239043271dbeace563a7686212a3dd517864a35623fe4d4a64ca19/dirty_equals-0.11-py3-none-any.whl", hash = "sha256:b1d7093273fc2f9be12f443a8ead954ef6daaf6746fd42ef3a5616433ee85286", size = 28051, upload-time = "2025-11-17T01:51:22.849Z" }, ] [[package]] @@ -417,11 +341,11 @@ wheels = [ [[package]] name = "docutils" -version = "0.22.3" +version = "0.22.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/02/111134bfeb6e6c7ac4c74594e39a59f6c0195dc4846afbeac3cba60f1927/docutils-0.22.3.tar.gz", hash = "sha256:21486ae730e4ca9f622677b1412b879af1791efcfba517e4c6f60be543fc8cdd", size = 2290153, upload-time = "2025-11-06T02:35:55.655Z" } +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/11/a8/c6a4b901d17399c77cd81fb001ce8961e9f5e04d3daf27e8925cb012e163/docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb", size = 633032, upload-time = "2025-11-06T02:35:52.391Z" }, + { 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" }, ] [[package]] @@ -439,60 +363,46 @@ wheels = [ [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, -] - -[[package]] -name = "fakeredis" -version = "2.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "redis" }, - { name = "sortedcontainers" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, -] - -[package.optional-dependencies] -lua = [ - { name = "lupa" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "fastmcp" -version = "2.14.0" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "httpx" }, + { name = "jsonref" }, { name = "jsonschema-path" }, { name = "mcp" }, { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, { name = "platformdirs" }, - { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "pydantic", extra = ["email"] }, - { name = "pydocket" }, { name = "pyperclip" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, + { name = "uncalled-for" }, { name = "uvicorn" }, + { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/50/9bb042a2d290ccadb35db3580ac507f192e1a39c489eb8faa167cd5e3b57/fastmcp-2.14.0.tar.gz", hash = "sha256:c1f487b36a3e4b043dbf3330e588830047df2e06f8ef0920d62dfb34d0905727", size = 8232562, upload-time = "2025-12-11T23:04:27.134Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/83/c95d3bf717698a693eccb43e137a32939d2549876e884e246028bff6ecce/fastmcp-3.1.1.tar.gz", hash = "sha256:db184b5391a31199323766a3abf3a8bfbb8010479f77eca84c0e554f18655c48", size = 17347644, upload-time = "2026-03-14T19:12:20.235Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/73/b5656172a6beb2eacec95f04403ddea1928e4b22066700fd14780f8f45d1/fastmcp-2.14.0-py3-none-any.whl", hash = "sha256:7b374c0bcaf1ef1ef46b9255ea84c607f354291eaf647ff56a47c69f5ec0c204", size = 398965, upload-time = "2025-12-11T23:04:25.587Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/570122de7e24f72138d006f799768e14cc1ccf7fcb22b7750b2bd276c711/fastmcp-3.1.1-py3-none-any.whl", hash = "sha256:8132ba069d89f14566b3266919d6d72e2ec23dd45d8944622dca407e9beda7eb", size = 633754, upload-time = "2026-03-14T19:12:22.736Z" }, ] [[package]] @@ -552,14 +462,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, + { 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" }, ] [[package]] @@ -585,26 +495,26 @@ wheels = [ [[package]] name = "jaraco-context" -version = "6.0.1" +version = "6.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/7b/c3081ff1af947915503121c649f26a778e1a2101fd525f74aef997d75b7e/jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581", size = 15832, upload-time = "2026-03-07T15:46:04.63Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, + { url = "https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl", hash = "sha256:0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808", size = 7005, upload-time = "2026-03-07T15:46:03.515Z" }, ] [[package]] name = "jaraco-functools" -version = "4.3.0" +version = "4.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" }, + { 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" }, ] [[package]] @@ -616,9 +526,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -626,24 +545,23 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +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 = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] name = "jsonschema-path" -version = "0.3.4" +version = "0.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pathable" }, { name = "pyyaml" }, { name = "referencing" }, - { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, + { 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" }, ] [[package]] @@ -660,7 +578,7 @@ wheels = [ [[package]] name = "keyring" -version = "25.6.0" +version = "25.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, @@ -671,83 +589,9 @@ dependencies = [ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "secretstorage", marker = "sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, -] - -[[package]] -name = "lupa" -version = "2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/15/713cab5d0dfa4858f83b99b3e0329072df33dc14fc3ebbaa017e0f9755c4/lupa-2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b3dabda836317e63c5ad052826e156610f356a04b3003dfa0dbe66b5d54d671", size = 954828, upload-time = "2025-10-24T07:17:15.726Z" }, - { url = "https://files.pythonhosted.org/packages/2e/71/704740cbc6e587dd6cc8dabf2f04820ac6a671784e57cc3c29db795476db/lupa-2.6-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8726d1c123bbe9fbb974ce29825e94121824e66003038ff4532c14cc2ed0c51c", size = 1919259, upload-time = "2025-10-24T07:17:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/eb/18/f248341c423c5d48837e35584c6c3eb4acab7e722b6057d7b3e28e42dae8/lupa-2.6-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:f4e159e7d814171199b246f9235ca8961f6461ea8c1165ab428afa13c9289a94", size = 984998, upload-time = "2025-10-24T07:17:20.428Z" }, - { url = "https://files.pythonhosted.org/packages/44/1e/8a4bd471e018aad76bcb9455d298c2c96d82eced20f2ae8fcec8cd800948/lupa-2.6-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:202160e80dbfddfb79316692a563d843b767e0f6787bbd1c455f9d54052efa6c", size = 1174871, upload-time = "2025-10-24T07:17:22.755Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5c/3a3f23fd6a91b0986eea1ceaf82ad3f9b958fe3515a9981fb9c4eb046c8b/lupa-2.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5deede7c5b36ab64f869dae4831720428b67955b0bb186c8349cf6ea121c852b", size = 1057471, upload-time = "2025-10-24T07:17:24.908Z" }, - { url = "https://files.pythonhosted.org/packages/45/ac/01be1fed778fb0c8f46ee8cbe344e4d782f6806fac12717f08af87aa4355/lupa-2.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86f04901f920bbf7c0cac56807dc9597e42347123e6f1f3ca920f15f54188ce5", size = 2100592, upload-time = "2025-10-24T07:17:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/3f/6c/1a05bb873e30830f8574e10cd0b4cdbc72e9dbad2a09e25810b5e3b1f75d/lupa-2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6deef8f851d6afb965c84849aa5b8c38856942df54597a811ce0369ced678610", size = 1081396, upload-time = "2025-10-24T07:17:29.064Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c2/a19dd80d6dc98b39bbf8135b8198e38aa7ca3360b720eac68d1d7e9286b5/lupa-2.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:21f2b5549681c2a13b1170a26159d30875d367d28f0247b81ca347222c755038", size = 1192007, upload-time = "2025-10-24T07:17:31.362Z" }, - { url = "https://files.pythonhosted.org/packages/4f/43/e1b297225c827f55752e46fdbfb021c8982081b0f24490e42776ea69ae3b/lupa-2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:66eea57630eab5e6f49fdc5d7811c0a2a41f2011be4ea56a087ea76112011eb7", size = 2196661, upload-time = "2025-10-24T07:17:33.484Z" }, - { url = "https://files.pythonhosted.org/packages/2e/8f/2272d429a7fa9dc8dbd6e9c5c9073a03af6007eb22a4c78829fec6a34b80/lupa-2.6-cp310-cp310-win32.whl", hash = "sha256:60a403de8cab262a4fe813085dd77010effa6e2eb1886db2181df803140533b1", size = 1412738, upload-time = "2025-10-24T07:17:35.11Z" }, - { url = "https://files.pythonhosted.org/packages/35/2a/1708911271dd49ad87b4b373b5a4b0e0a0516d3d2af7b76355946c7ee171/lupa-2.6-cp310-cp310-win_amd64.whl", hash = "sha256:e4656a39d93dfa947cf3db56dc16c7916cb0cc8024acd3a952071263f675df64", size = 1656898, upload-time = "2025-10-24T07:17:36.949Z" }, - { url = "https://files.pythonhosted.org/packages/ca/29/1f66907c1ebf1881735afa695e646762c674f00738ebf66d795d59fc0665/lupa-2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d988c0f9331b9f2a5a55186701a25444ab10a1432a1021ee58011499ecbbdd5", size = 962875, upload-time = "2025-10-24T07:17:39.107Z" }, - { url = "https://files.pythonhosted.org/packages/e6/67/4a748604be360eb9c1c215f6a0da921cd1a2b44b2c5951aae6fb83019d3a/lupa-2.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:ebe1bbf48259382c72a6fe363dea61a0fd6fe19eab95e2ae881e20f3654587bf", size = 1935390, upload-time = "2025-10-24T07:17:41.427Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0c/8ef9ee933a350428b7bdb8335a37ef170ab0bb008bbf9ca8f4f4310116b6/lupa-2.6-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:a8fcee258487cf77cdd41560046843bb38c2e18989cd19671dd1e2596f798306", size = 992193, upload-time = "2025-10-24T07:17:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/65/46/e6c7facebdb438db8a65ed247e56908818389c1a5abbf6a36aab14f1057d/lupa-2.6-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:561a8e3be800827884e767a694727ed8482d066e0d6edfcbf423b05e63b05535", size = 1165844, upload-time = "2025-10-24T07:17:45.437Z" }, - { url = "https://files.pythonhosted.org/packages/1c/26/9f1154c6c95f175ccbf96aa96c8f569c87f64f463b32473e839137601a8b/lupa-2.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af880a62d47991cae78b8e9905c008cbfdc4a3a9723a66310c2634fc7644578c", size = 1048069, upload-time = "2025-10-24T07:17:47.181Z" }, - { url = "https://files.pythonhosted.org/packages/68/67/2cc52ab73d6af81612b2ea24c870d3fa398443af8e2875e5befe142398b1/lupa-2.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b22923aa4023c86c0097b235615f89d469a0c4eee0489699c494d3367c4c85", size = 2079079, upload-time = "2025-10-24T07:17:49.755Z" }, - { url = "https://files.pythonhosted.org/packages/2e/dc/f843f09bbf325f6e5ee61730cf6c3409fc78c010d968c7c78acba3019ca7/lupa-2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:153d2cc6b643f7efb9cfc0c6bb55ec784d5bac1a3660cfc5b958a7b8f38f4a75", size = 1071428, upload-time = "2025-10-24T07:17:51.991Z" }, - { url = "https://files.pythonhosted.org/packages/2e/60/37533a8d85bf004697449acb97ecdacea851acad28f2ad3803662487dd2a/lupa-2.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3fa8777e16f3ded50b72967dc17e23f5a08e4f1e2c9456aff2ebdb57f5b2869f", size = 1181756, upload-time = "2025-10-24T07:17:53.752Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/cf29b20dbb4927b6a3d27c339ac5d73e74306ecc28c8e2c900b2794142ba/lupa-2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8dbdcbe818c02a2f56f5ab5ce2de374dab03e84b25266cfbaef237829bc09b3f", size = 2175687, upload-time = "2025-10-24T07:17:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/94/7c/050e02f80c7131b63db1474bff511e63c545b5a8636a24cbef3fc4da20b6/lupa-2.6-cp311-cp311-win32.whl", hash = "sha256:defaf188fde8f7a1e5ce3a5e6d945e533b8b8d547c11e43b96c9b7fe527f56dc", size = 1412592, upload-time = "2025-10-24T07:17:59.062Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/6f2af98aa5d771cea661f66c8eb8f53772ec1ab1dfbce24126cfcd189436/lupa-2.6-cp311-cp311-win_amd64.whl", hash = "sha256:9505ae600b5c14f3e17e70f87f88d333717f60411faca1ddc6f3e61dce85fa9e", size = 1669194, upload-time = "2025-10-24T07:18:01.647Z" }, - { url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" }, - { url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" }, - { url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" }, - { url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" }, - { url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" }, - { url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" }, - { url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" }, - { url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" }, - { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" }, - { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" }, - { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" }, - { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" }, - { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" }, - { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" }, - { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" }, - { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" }, - { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" }, - { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" }, - { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" }, - { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" }, - { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" }, - { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" }, - { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" }, - { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] [[package]] @@ -764,7 +608,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.25.0" +version = "1.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -782,9 +626,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] [[package]] @@ -819,107 +663,42 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, -] - -[[package]] -name = "opentelemetry-exporter-prometheus" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "prometheus-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, + { 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" }, ] [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { 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" }, ] [[package]] name = "pathable" -version = "0.4.4" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, -] - -[[package]] -name = "pathvalidate" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, + { 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" }, ] [[package]] name = "platformdirs" -version = "4.5.0" +version = "4.9.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, + { 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" }, ] [[package]] @@ -931,32 +710,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "prometheus-client" -version = "0.24.1" -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" } -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" }, -] - [[package]] name = "py-key-value-aio" -version = "0.3.0" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, - { name = "py-key-value-shared" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, + { 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" }, ] [package.optional-dependencies] -disk = [ - { name = "diskcache" }, - { name = "pathvalidate" }, +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, ] keyring = [ { name = "keyring" }, @@ -964,35 +734,19 @@ keyring = [ memory = [ { name = "cachetools" }, ] -redis = [ - { name = "redis" }, -] - -[[package]] -name = "py-key-value-shared" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, -] [[package]] name = "pycparser" -version = "2.23" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] name = "pydantic" -version = "2.12.4" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1000,9 +754,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, + { 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" }, ] [package.optional-dependencies] @@ -1130,40 +884,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.11.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, -] - -[[package]] -name = "pydocket" -version = "0.16.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cloudpickle" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "fakeredis", extra = ["lua"] }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-prometheus" }, - { name = "opentelemetry-instrumentation" }, - { name = "prometheus-client" }, - { name = "py-key-value-aio", extra = ["memory", "redis"] }, - { name = "python-json-logger" }, - { name = "redis" }, - { name = "rich" }, - { name = "typer" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/00/26befe5f58df7cd1aeda4a8d10bc7d1908ffd86b80fd995e57a2a7b3f7bd/pydocket-0.16.6.tar.gz", hash = "sha256:b96c96ad7692827214ed4ff25fcf941ec38371314db5dcc1ae792b3e9d3a0294", size = 299054, upload-time = "2026-01-09T22:09:15.405Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/3f/7483e5a6dc6326b6e0c640619b5c5bd1d6e3c20e54d58f5fb86267cef00e/pydocket-0.16.6-py3-none-any.whl", hash = "sha256:683d21e2e846aa5106274e7d59210331b242d7fb0dce5b08d3b82065663ed183", size = 67697, upload-time = "2026-01-09T22:09:13.436Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] @@ -1177,11 +907,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies] @@ -1200,7 +933,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1211,41 +944,32 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.2.0" +version = "1.3.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/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, + { 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" }, ] [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - -[[package]] -name = "python-json-logger" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -1352,58 +1076,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "redis" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, -] - [[package]] name = "referencing" -version = "0.36.2" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] name = "rich" -version = "14.2.0" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, + { 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" }, ] [[package]] @@ -1421,189 +1118,163 @@ wheels = [ [[package]] name = "rpds-py" -version = "0.28.0" +version = "0.30.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" } +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/82/f8/13bb772dc7cbf2c3c5b816febc34fa0cb2c64a08e0569869585684ce6631/rpds_py-0.28.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7b6013db815417eeb56b2d9d7324e64fcd4fa289caeee6e7a78b2e11fc9b438a", size = 362820, upload-time = "2025-10-22T22:21:15.074Z" }, - { url = "https://files.pythonhosted.org/packages/84/91/6acce964aab32469c3dbe792cb041a752d64739c534e9c493c701ef0c032/rpds_py-0.28.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a4c6b05c685c0c03f80dabaeb73e74218c49deea965ca63f76a752807397207", size = 348499, upload-time = "2025-10-22T22:21:17.658Z" }, - { url = "https://files.pythonhosted.org/packages/f1/93/c05bb1f4f5e0234db7c4917cb8dd5e2e0a9a7b26dc74b1b7bee3c9cfd477/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4794c6c3fbe8f9ac87699b131a1f26e7b4abcf6d828da46a3a52648c7930eba", size = 379356, upload-time = "2025-10-22T22:21:19.847Z" }, - { url = "https://files.pythonhosted.org/packages/5c/37/e292da436f0773e319753c567263427cdf6c645d30b44f09463ff8216cda/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e8456b6ee5527112ff2354dd9087b030e3429e43a74f480d4a5ca79d269fd85", size = 390151, upload-time = "2025-10-22T22:21:21.569Z" }, - { url = "https://files.pythonhosted.org/packages/76/87/a4e3267131616e8faf10486dc00eaedf09bd61c87f01e5ef98e782ee06c9/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:beb880a9ca0a117415f241f66d56025c02037f7c4efc6fe59b5b8454f1eaa50d", size = 524831, upload-time = "2025-10-22T22:21:23.394Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c8/4a4ca76f0befae9515da3fad11038f0fce44f6bb60b21fe9d9364dd51fb0/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6897bebb118c44b38c9cb62a178e09f1593c949391b9a1a6fe777ccab5934ee7", size = 404687, upload-time = "2025-10-22T22:21:25.201Z" }, - { url = "https://files.pythonhosted.org/packages/6a/65/118afe854424456beafbbebc6b34dcf6d72eae3a08b4632bc4220f8240d9/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b553dd06e875249fd43efd727785efb57a53180e0fde321468222eabbeaafa", size = 382683, upload-time = "2025-10-22T22:21:26.536Z" }, - { url = "https://files.pythonhosted.org/packages/f7/bc/0625064041fb3a0c77ecc8878c0e8341b0ae27ad0f00cf8f2b57337a1e63/rpds_py-0.28.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:f0b2044fdddeea5b05df832e50d2a06fe61023acb44d76978e1b060206a8a476", size = 398927, upload-time = "2025-10-22T22:21:27.864Z" }, - { url = "https://files.pythonhosted.org/packages/5d/1a/fed7cf2f1ee8a5e4778f2054153f2cfcf517748875e2f5b21cf8907cd77d/rpds_py-0.28.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05cf1e74900e8da73fa08cc76c74a03345e5a3e37691d07cfe2092d7d8e27b04", size = 411590, upload-time = "2025-10-22T22:21:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/a8e0f67fa374a6c472dbb0afdaf1ef744724f165abb6899f20e2f1563137/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:efd489fec7c311dae25e94fe7eeda4b3d06be71c68f2cf2e8ef990ffcd2cd7e8", size = 559843, upload-time = "2025-10-22T22:21:30.917Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ea/e10353f6d7c105be09b8135b72787a65919971ae0330ad97d87e4e199880/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ada7754a10faacd4f26067e62de52d6af93b6d9542f0df73c57b9771eb3ba9c4", size = 584188, upload-time = "2025-10-22T22:21:32.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/b0/a19743e0763caf0c89f6fc6ba6fbd9a353b24ffb4256a492420c5517da5a/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c2a34fd26588949e1e7977cfcbb17a9a42c948c100cab890c6d8d823f0586457", size = 550052, upload-time = "2025-10-22T22:21:34.702Z" }, - { url = "https://files.pythonhosted.org/packages/de/bc/ec2c004f6c7d6ab1e25dae875cdb1aee087c3ebed5b73712ed3000e3851a/rpds_py-0.28.0-cp310-cp310-win32.whl", hash = "sha256:f9174471d6920cbc5e82a7822de8dfd4dcea86eb828b04fc8c6519a77b0ee51e", size = 215110, upload-time = "2025-10-22T22:21:36.645Z" }, - { url = "https://files.pythonhosted.org/packages/6c/de/4ce8abf59674e17187023933547d2018363e8fc76ada4f1d4d22871ccb6e/rpds_py-0.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:6e32dd207e2c4f8475257a3540ab8a93eff997abfa0a3fdb287cae0d6cd874b8", size = 223850, upload-time = "2025-10-22T22:21:38.006Z" }, - { url = "https://files.pythonhosted.org/packages/a6/34/058d0db5471c6be7bef82487ad5021ff8d1d1d27794be8730aad938649cf/rpds_py-0.28.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:03065002fd2e287725d95fbc69688e0c6daf6c6314ba38bdbaa3895418e09296", size = 362344, upload-time = "2025-10-22T22:21:39.713Z" }, - { url = "https://files.pythonhosted.org/packages/5d/67/9503f0ec8c055a0782880f300c50a2b8e5e72eb1f94dfc2053da527444dd/rpds_py-0.28.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28ea02215f262b6d078daec0b45344c89e161eab9526b0d898221d96fdda5f27", size = 348440, upload-time = "2025-10-22T22:21:41.056Z" }, - { url = "https://files.pythonhosted.org/packages/68/2e/94223ee9b32332a41d75b6f94b37b4ce3e93878a556fc5f152cbd856a81f/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25dbade8fbf30bcc551cb352376c0ad64b067e4fc56f90e22ba70c3ce205988c", size = 379068, upload-time = "2025-10-22T22:21:42.593Z" }, - { url = "https://files.pythonhosted.org/packages/b4/25/54fd48f9f680cfc44e6a7f39a5fadf1d4a4a1fd0848076af4a43e79f998c/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c03002f54cc855860bfdc3442928ffdca9081e73b5b382ed0b9e8efe6e5e205", size = 390518, upload-time = "2025-10-22T22:21:43.998Z" }, - { url = "https://files.pythonhosted.org/packages/1b/85/ac258c9c27f2ccb1bd5d0697e53a82ebcf8088e3186d5d2bf8498ee7ed44/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9699fa7990368b22032baf2b2dce1f634388e4ffc03dfefaaac79f4695edc95", size = 525319, upload-time = "2025-10-22T22:21:45.645Z" }, - { url = "https://files.pythonhosted.org/packages/40/cb/c6734774789566d46775f193964b76627cd5f42ecf246d257ce84d1912ed/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9b06fe1a75e05e0713f06ea0c89ecb6452210fd60e2f1b6ddc1067b990e08d9", size = 404896, upload-time = "2025-10-22T22:21:47.544Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/14e37ce83202c632c89b0691185dca9532288ff9d390eacae3d2ff771bae/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9f83e7b326a3f9ec3ef84cda98fb0a74c7159f33e692032233046e7fd15da2", size = 382862, upload-time = "2025-10-22T22:21:49.176Z" }, - { url = "https://files.pythonhosted.org/packages/6a/83/f3642483ca971a54d60caa4449f9d6d4dbb56a53e0072d0deff51b38af74/rpds_py-0.28.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:0d3259ea9ad8743a75a43eb7819324cdab393263c91be86e2d1901ee65c314e0", size = 398848, upload-time = "2025-10-22T22:21:51.024Z" }, - { url = "https://files.pythonhosted.org/packages/44/09/2d9c8b2f88e399b4cfe86efdf2935feaf0394e4f14ab30c6c5945d60af7d/rpds_py-0.28.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a7548b345f66f6695943b4ef6afe33ccd3f1b638bd9afd0f730dd255c249c9e", size = 412030, upload-time = "2025-10-22T22:21:52.665Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f5/e1cec473d4bde6df1fd3738be8e82d64dd0600868e76e92dfeaebbc2d18f/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9a40040aa388b037eb39416710fbcce9443498d2eaab0b9b45ae988b53f5c67", size = 559700, upload-time = "2025-10-22T22:21:54.123Z" }, - { url = "https://files.pythonhosted.org/packages/8d/be/73bb241c1649edbf14e98e9e78899c2c5e52bbe47cb64811f44d2cc11808/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f60c7ea34e78c199acd0d3cda37a99be2c861dd2b8cf67399784f70c9f8e57d", size = 584581, upload-time = "2025-10-22T22:21:56.102Z" }, - { url = "https://files.pythonhosted.org/packages/9c/9c/ffc6e9218cd1eb5c2c7dbd276c87cd10e8c2232c456b554169eb363381df/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1571ae4292649100d743b26d5f9c63503bb1fedf538a8f29a98dce2d5ba6b4e6", size = 549981, upload-time = "2025-10-22T22:21:58.253Z" }, - { url = "https://files.pythonhosted.org/packages/5f/50/da8b6d33803a94df0149345ee33e5d91ed4d25fc6517de6a25587eae4133/rpds_py-0.28.0-cp311-cp311-win32.whl", hash = "sha256:5cfa9af45e7c1140af7321fa0bef25b386ee9faa8928c80dc3a5360971a29e8c", size = 214729, upload-time = "2025-10-22T22:21:59.625Z" }, - { url = "https://files.pythonhosted.org/packages/12/fd/b0f48c4c320ee24c8c20df8b44acffb7353991ddf688af01eef5f93d7018/rpds_py-0.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:dd8d86b5d29d1b74100982424ba53e56033dc47720a6de9ba0259cf81d7cecaa", size = 223977, upload-time = "2025-10-22T22:22:01.092Z" }, - { url = "https://files.pythonhosted.org/packages/b4/21/c8e77a2ac66e2ec4e21f18a04b4e9a0417ecf8e61b5eaeaa9360a91713b4/rpds_py-0.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e27d3a5709cc2b3e013bf93679a849213c79ae0573f9b894b284b55e729e120", size = 217326, upload-time = "2025-10-22T22:22:02.944Z" }, - { url = "https://files.pythonhosted.org/packages/b8/5c/6c3936495003875fe7b14f90ea812841a08fca50ab26bd840e924097d9c8/rpds_py-0.28.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b4f28583a4f247ff60cd7bdda83db8c3f5b05a7a82ff20dd4b078571747708f", size = 366439, upload-time = "2025-10-22T22:22:04.525Z" }, - { url = "https://files.pythonhosted.org/packages/56/f9/a0f1ca194c50aa29895b442771f036a25b6c41a35e4f35b1a0ea713bedae/rpds_py-0.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d678e91b610c29c4b3d52a2c148b641df2b4676ffe47c59f6388d58b99cdc424", size = 348170, upload-time = "2025-10-22T22:22:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/18/ea/42d243d3a586beb72c77fa5def0487daf827210069a95f36328e869599ea/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e819e0e37a44a78e1383bf1970076e2ccc4dc8c2bbaa2f9bd1dc987e9afff628", size = 378838, upload-time = "2025-10-22T22:22:07.932Z" }, - { url = "https://files.pythonhosted.org/packages/e7/78/3de32e18a94791af8f33601402d9d4f39613136398658412a4e0b3047327/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ee514e0f0523db5d3fb171f397c54875dbbd69760a414dccf9d4d7ad628b5bd", size = 393299, upload-time = "2025-10-22T22:22:09.435Z" }, - { url = "https://files.pythonhosted.org/packages/13/7e/4bdb435afb18acea2eb8a25ad56b956f28de7c59f8a1d32827effa0d4514/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3fa06d27fdcee47f07a39e02862da0100cb4982508f5ead53ec533cd5fe55e", size = 518000, upload-time = "2025-10-22T22:22:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/31/d0/5f52a656875cdc60498ab035a7a0ac8f399890cc1ee73ebd567bac4e39ae/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46959ef2e64f9e4a41fc89aa20dbca2b85531f9a72c21099a3360f35d10b0d5a", size = 408746, upload-time = "2025-10-22T22:22:13.143Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/49ce51767b879cde77e7ad9fae164ea15dce3616fe591d9ea1df51152706/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8455933b4bcd6e83fde3fefc987a023389c4b13f9a58c8d23e4b3f6d13f78c84", size = 386379, upload-time = "2025-10-22T22:22:14.602Z" }, - { url = "https://files.pythonhosted.org/packages/6a/99/e4e1e1ee93a98f72fc450e36c0e4d99c35370220e815288e3ecd2ec36a2a/rpds_py-0.28.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ad50614a02c8c2962feebe6012b52f9802deec4263946cddea37aaf28dd25a66", size = 401280, upload-time = "2025-10-22T22:22:16.063Z" }, - { url = "https://files.pythonhosted.org/packages/61/35/e0c6a57488392a8b319d2200d03dad2b29c0db9996f5662c3b02d0b86c02/rpds_py-0.28.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5deca01b271492553fdb6c7fd974659dce736a15bae5dad7ab8b93555bceb28", size = 412365, upload-time = "2025-10-22T22:22:17.504Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6a/841337980ea253ec797eb084665436007a1aad0faac1ba097fb906c5f69c/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:735f8495a13159ce6a0d533f01e8674cec0c57038c920495f87dcb20b3ddb48a", size = 559573, upload-time = "2025-10-22T22:22:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/e7/5e/64826ec58afd4c489731f8b00729c5f6afdb86f1df1df60bfede55d650bb/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:961ca621ff10d198bbe6ba4957decca61aa2a0c56695384c1d6b79bf61436df5", size = 583973, upload-time = "2025-10-22T22:22:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ee/44d024b4843f8386a4eeaa4c171b3d31d55f7177c415545fd1a24c249b5d/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2374e16cc9131022e7d9a8f8d65d261d9ba55048c78f3b6e017971a4f5e6353c", size = 553800, upload-time = "2025-10-22T22:22:22.25Z" }, - { url = "https://files.pythonhosted.org/packages/7d/89/33e675dccff11a06d4d85dbb4d1865f878d5020cbb69b2c1e7b2d3f82562/rpds_py-0.28.0-cp312-cp312-win32.whl", hash = "sha256:d15431e334fba488b081d47f30f091e5d03c18527c325386091f31718952fe08", size = 216954, upload-time = "2025-10-22T22:22:24.105Z" }, - { url = "https://files.pythonhosted.org/packages/af/36/45f6ebb3210887e8ee6dbf1bc710ae8400bb417ce165aaf3024b8360d999/rpds_py-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:a410542d61fc54710f750d3764380b53bf09e8c4edbf2f9141a82aa774a04f7c", size = 227844, upload-time = "2025-10-22T22:22:25.551Z" }, - { url = "https://files.pythonhosted.org/packages/57/91/f3fb250d7e73de71080f9a221d19bd6a1c1eb0d12a1ea26513f6c1052ad6/rpds_py-0.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd", size = 217624, upload-time = "2025-10-22T22:22:26.914Z" }, - { url = "https://files.pythonhosted.org/packages/d3/03/ce566d92611dfac0085c2f4b048cd53ed7c274a5c05974b882a908d540a2/rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b", size = 366235, upload-time = "2025-10-22T22:22:28.397Z" }, - { url = "https://files.pythonhosted.org/packages/00/34/1c61da1b25592b86fd285bd7bd8422f4c9d748a7373b46126f9ae792a004/rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a", size = 348241, upload-time = "2025-10-22T22:22:30.171Z" }, - { url = "https://files.pythonhosted.org/packages/fc/00/ed1e28616848c61c493a067779633ebf4b569eccaacf9ccbdc0e7cba2b9d/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa", size = 378079, upload-time = "2025-10-22T22:22:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/11/b2/ccb30333a16a470091b6e50289adb4d3ec656fd9951ba8c5e3aaa0746a67/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724", size = 393151, upload-time = "2025-10-22T22:22:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d0/73e2217c3ee486d555cb84920597480627d8c0240ff3062005c6cc47773e/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491", size = 517520, upload-time = "2025-10-22T22:22:34.949Z" }, - { url = "https://files.pythonhosted.org/packages/c4/91/23efe81c700427d0841a4ae7ea23e305654381831e6029499fe80be8a071/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399", size = 408699, upload-time = "2025-10-22T22:22:36.584Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ee/a324d3198da151820a326c1f988caaa4f37fc27955148a76fff7a2d787a9/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6", size = 385720, upload-time = "2025-10-22T22:22:38.014Z" }, - { url = "https://files.pythonhosted.org/packages/19/ad/e68120dc05af8b7cab4a789fccd8cdcf0fe7e6581461038cc5c164cd97d2/rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d", size = 401096, upload-time = "2025-10-22T22:22:39.869Z" }, - { url = "https://files.pythonhosted.org/packages/99/90/c1e070620042459d60df6356b666bb1f62198a89d68881816a7ed121595a/rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb", size = 411465, upload-time = "2025-10-22T22:22:41.395Z" }, - { url = "https://files.pythonhosted.org/packages/68/61/7c195b30d57f1b8d5970f600efee72a4fad79ec829057972e13a0370fd24/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41", size = 558832, upload-time = "2025-10-22T22:22:42.871Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3d/06f3a718864773f69941d4deccdf18e5e47dd298b4628062f004c10f3b34/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7", size = 583230, upload-time = "2025-10-22T22:22:44.877Z" }, - { url = "https://files.pythonhosted.org/packages/66/df/62fc783781a121e77fee9a21ead0a926f1b652280a33f5956a5e7833ed30/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9", size = 553268, upload-time = "2025-10-22T22:22:46.441Z" }, - { url = "https://files.pythonhosted.org/packages/84/85/d34366e335140a4837902d3dea89b51f087bd6a63c993ebdff59e93ee61d/rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5", size = 217100, upload-time = "2025-10-22T22:22:48.342Z" }, - { url = "https://files.pythonhosted.org/packages/3c/1c/f25a3f3752ad7601476e3eff395fe075e0f7813fbb9862bd67c82440e880/rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e", size = 227759, upload-time = "2025-10-22T22:22:50.219Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d6/5f39b42b99615b5bc2f36ab90423ea404830bdfee1c706820943e9a645eb/rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1", size = 217326, upload-time = "2025-10-22T22:22:51.647Z" }, - { url = "https://files.pythonhosted.org/packages/5c/8b/0c69b72d1cee20a63db534be0df271effe715ef6c744fdf1ff23bb2b0b1c/rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c", size = 355736, upload-time = "2025-10-22T22:22:53.211Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6d/0c2ee773cfb55c31a8514d2cece856dd299170a49babd50dcffb15ddc749/rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa", size = 342677, upload-time = "2025-10-22T22:22:54.723Z" }, - { url = "https://files.pythonhosted.org/packages/e2/1c/22513ab25a27ea205144414724743e305e8153e6abe81833b5e678650f5a/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b", size = 371847, upload-time = "2025-10-22T22:22:56.295Z" }, - { url = "https://files.pythonhosted.org/packages/60/07/68e6ccdb4b05115ffe61d31afc94adef1833d3a72f76c9632d4d90d67954/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d", size = 381800, upload-time = "2025-10-22T22:22:57.808Z" }, - { url = "https://files.pythonhosted.org/packages/73/bf/6d6d15df80781d7f9f368e7c1a00caf764436518c4877fb28b029c4624af/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe", size = 518827, upload-time = "2025-10-22T22:22:59.826Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d3/2decbb2976cc452cbf12a2b0aaac5f1b9dc5dd9d1f7e2509a3ee00421249/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a", size = 399471, upload-time = "2025-10-22T22:23:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2c/f30892f9e54bd02e5faca3f6a26d6933c51055e67d54818af90abed9748e/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc", size = 377578, upload-time = "2025-10-22T22:23:03.52Z" }, - { url = "https://files.pythonhosted.org/packages/f0/5d/3bce97e5534157318f29ac06bf2d279dae2674ec12f7cb9c12739cee64d8/rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259", size = 390482, upload-time = "2025-10-22T22:23:05.391Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f0/886bd515ed457b5bd93b166175edb80a0b21a210c10e993392127f1e3931/rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a", size = 402447, upload-time = "2025-10-22T22:23:06.93Z" }, - { url = "https://files.pythonhosted.org/packages/42/b5/71e8777ac55e6af1f4f1c05b47542a1eaa6c33c1cf0d300dca6a1c6e159a/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f", size = 552385, upload-time = "2025-10-22T22:23:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cb/6ca2d70cbda5a8e36605e7788c4aa3bea7c17d71d213465a5a675079b98d/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37", size = 575642, upload-time = "2025-10-22T22:23:10.348Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d4/407ad9960ca7856d7b25c96dcbe019270b5ffdd83a561787bc682c797086/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712", size = 544507, upload-time = "2025-10-22T22:23:12.434Z" }, - { url = "https://files.pythonhosted.org/packages/51/31/2f46fe0efcac23fbf5797c6b6b7e1c76f7d60773e525cb65fcbc582ee0f2/rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342", size = 205376, upload-time = "2025-10-22T22:23:13.979Z" }, - { url = "https://files.pythonhosted.org/packages/92/e4/15947bda33cbedfc134490a41841ab8870a72a867a03d4969d886f6594a2/rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907", size = 215907, upload-time = "2025-10-22T22:23:15.5Z" }, - { url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" }, - { url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" }, - { url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" }, - { url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" }, - { url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" }, - { url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" }, - { url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" }, - { url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" }, - { url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" }, - { url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" }, - { url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" }, - { url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" }, - { url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" }, - { url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" }, - { url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" }, - { url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" }, - { url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" }, - { url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" }, - { url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" }, - { url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" }, - { url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" }, - { url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" }, - { url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" }, - { url = "https://files.pythonhosted.org/packages/ae/bc/b43f2ea505f28119bd551ae75f70be0c803d2dbcd37c1b3734909e40620b/rpds_py-0.28.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f5e7101145427087e493b9c9b959da68d357c28c562792300dd21a095118ed16", size = 363913, upload-time = "2025-10-22T22:24:07.129Z" }, - { url = "https://files.pythonhosted.org/packages/28/f2/db318195d324c89a2c57dc5195058cbadd71b20d220685c5bd1da79ee7fe/rpds_py-0.28.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:31eb671150b9c62409a888850aaa8e6533635704fe2b78335f9aaf7ff81eec4d", size = 350452, upload-time = "2025-10-22T22:24:08.754Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/1391c819b8573a4898cedd6b6c5ec5bc370ce59e5d6bdcebe3c9c1db4588/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b55c1f64482f7d8bd39942f376bfdf2f6aec637ee8c805b5041e14eeb771db", size = 380957, upload-time = "2025-10-22T22:24:10.826Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5c/e5de68ee7eb7248fce93269833d1b329a196d736aefb1a7481d1e99d1222/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24743a7b372e9a76171f6b69c01aedf927e8ac3e16c474d9fe20d552a8cb45c7", size = 391919, upload-time = "2025-10-22T22:24:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/fb/4f/2376336112cbfeb122fd435d608ad8d5041b3aed176f85a3cb32c262eb80/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:389c29045ee8bbb1627ea190b4976a310a295559eaf9f1464a1a6f2bf84dde78", size = 528541, upload-time = "2025-10-22T22:24:14.197Z" }, - { url = "https://files.pythonhosted.org/packages/68/53/5ae232e795853dd20da7225c5dd13a09c0a905b1a655e92bdf8d78a99fd9/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23690b5827e643150cf7b49569679ec13fe9a610a15949ed48b85eb7f98f34ec", size = 405629, upload-time = "2025-10-22T22:24:16.001Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2d/351a3b852b683ca9b6b8b38ed9efb2347596973849ba6c3a0e99877c10aa/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f0c9266c26580e7243ad0d72fc3e01d6b33866cfab5084a6da7576bcf1c4f72", size = 384123, upload-time = "2025-10-22T22:24:17.585Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/870804daa00202728cc91cb8e2385fa9f1f4eb49857c49cfce89e304eae6/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4c6c4db5d73d179746951486df97fd25e92396be07fc29ee8ff9a8f5afbdfb27", size = 400923, upload-time = "2025-10-22T22:24:19.512Z" }, - { url = "https://files.pythonhosted.org/packages/53/25/3706b83c125fa2a0bccceac951de3f76631f6bd0ee4d02a0ed780712ef1b/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3b695a8fa799dd2cfdb4804b37096c5f6dba1ac7f48a7fbf6d0485bcd060316", size = 413767, upload-time = "2025-10-22T22:24:21.316Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f9/ce43dbe62767432273ed2584cef71fef8411bddfb64125d4c19128015018/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:6aa1bfce3f83baf00d9c5fcdbba93a3ab79958b4c7d7d1f55e7fe68c20e63912", size = 561530, upload-time = "2025-10-22T22:24:22.958Z" }, - { url = "https://files.pythonhosted.org/packages/46/c9/ffe77999ed8f81e30713dd38fd9ecaa161f28ec48bb80fa1cd9118399c27/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:7b0f9dceb221792b3ee6acb5438eb1f02b0cb2c247796a72b016dcc92c6de829", size = 585453, upload-time = "2025-10-22T22:24:24.779Z" }, - { url = "https://files.pythonhosted.org/packages/ed/d2/4a73b18821fd4669762c855fd1f4e80ceb66fb72d71162d14da58444a763/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5d0145edba8abd3db0ab22b5300c99dc152f5c9021fab861be0f0544dc3cbc5f", size = 552199, upload-time = "2025-10-22T22:24:26.54Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] [[package]] name = "secretstorage" -version = "3.4.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "jeepney" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/9f/11ef35cf1027c1339552ea7bfe6aaa74a8516d8b5caf6e7d338daf54fd80/secretstorage-3.4.0.tar.gz", hash = "sha256:c46e216d6815aff8a8a18706a2fbfd8d53fcbb0dce99301881687a1b0289ef7c", size = 19748, upload-time = "2025-09-09T16:42:13.859Z" } +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 = [ - { url = "https://files.pythonhosted.org/packages/91/ff/2e2eed29e02c14a5cb6c57f09b2d5b40e65d6cc71f45b52e0be295ccbc2f/secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e", size = 15272, upload-time = "2025-09-09T16:42:12.744Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] [[package]] name = "sse-starlette" -version = "3.0.3" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" }, ] [[package]] name = "starlette" -version = "0.50.0" +version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] [[package]] @@ -1627,66 +1298,56 @@ requires-dist = [ [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, -] - -[[package]] -name = "typer" -version = "0.21.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] @@ -1711,154 +1372,197 @@ wheels = [ ] [[package]] -name = "urllib3" -version = "2.6.3" +name = "uncalled-for" +version = "0.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { 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" }, ] [[package]] name = "uvicorn" -version = "0.38.0" +version = "0.41.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/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +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" } +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" }, ] [[package]] name = "websockets" -version = "15.0.1" +version = "16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, - { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, - { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, - { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, - { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, - { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, -] - -[[package]] -name = "wrapt" -version = "1.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, - { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, - { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, - { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, - { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, - { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, - { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, - { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, - { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, - { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, - { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, - { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, - { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, - { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, - { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, - { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, - { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, - { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, - { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, - { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, + { 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" }, ] [[package]] diff --git a/loq.toml b/loq.toml index 0fffe3d5c..d495ee57f 100644 --- a/loq.toml +++ b/loq.toml @@ -4,7 +4,7 @@ default_max_lines = 1000 respect_gitignore = true -exclude = ["**/uv.lock", ".git/**", "docs/**"] +exclude = ["**/uv.lock", ".git/**", ".claude/**", "docs/**"] [[rules]] path = "tests/**" diff --git a/pyproject.toml b/pyproject.toml index 320938b2e..be8794e95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,10 +52,11 @@ classifiers = [ ] [project.optional-dependencies] -anthropic = ["anthropic>=0.40.0"] -apps = ["prefab-ui>=0.6.0"] -azure = ["azure-identity>=1.16.0"] -code-mode = ["pydantic-monty>=0.0.7"] +anthropic = ["anthropic>=0.48.0"] +apps = ["prefab-ui>=0.18.0"] +# PyJWT floor: transitive via msal; CVE-2026-32597 affects <= 2.11.0 +azure = ["azure-identity>=1.16.0", "PyJWT>=2.12.0"] +code-mode = ["pydantic-monty==0.0.9"] gemini = ["google-genai>=1.18.0"] openai = ["openai>=1.102.0"] tasks = ["pydocket>=0.18.0"] @@ -84,7 +85,7 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-xdist>=3.6.1", "ruff>=0.12.8", - "ty>=0.0.20", + "ty>=0.0.26", "prek>=0.2.12", "loq>=0.1.0a3", "opentelemetry-exporter-otlp-proto-grpc>=1.39.0", @@ -138,6 +139,7 @@ env = [ markers = [ "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", "client_process: marks tests that spawn client processes via stdio transport. These can create issues when run in the same CI environment as other subprocess-based tests.", + "conformance: marks MCP conformance tests (require Node.js/npx)", ] # Automatically mark all tests in integration_tests folder pythonpath = ["."] @@ -197,4 +199,4 @@ known-first-party = ["fastmcp"] [tool.codespell] -ignore-words-list = "asend,shttp,te" +ignore-words-list = "asend,shttp,te" \ No newline at end of file diff --git a/scripts/auto_close_needs_mre.py b/scripts/auto_close_needs_mre.py index 15c525976..a9185f584 100644 --- a/scripts/auto_close_needs_mre.py +++ b/scripts/auto_close_needs_mre.py @@ -215,9 +215,29 @@ class GitHubClient: return timeline - def close_issue(self, issue_number: int, comment: str) -> bool: - """Close an issue with a comment.""" - # First add the comment + def close_issue(self, issue_number: int, comment: str) -> tuple[bool, bool]: + """Close an issue with a comment. + + Closes first, then comments — so a failed comment never leaves + a misleading "closing" notice on a still-open issue. + + Returns (closed, commented) so the caller can log partial failures. + """ + # Close the issue first + issue_url = f"{self.base_url}/issues/{issue_number}" + with httpx.Client() as client: + response = client.patch( + issue_url, headers=self.headers, json={"state": "closed"} + ) + + if response.status_code != 200: + print( + f"Failed to close issue #{issue_number}: " + f"{response.status_code} {response.text}" + ) + return False, False + + # Then add the comment comment_url = f"{self.base_url}/issues/{issue_number}/comments" with httpx.Client() as client: response = client.post( @@ -225,17 +245,13 @@ class GitHubClient: ) if response.status_code != 201: - print(f"Failed to add comment to issue #{issue_number}") - return False + print( + f"Issue #{issue_number} was closed but comment failed: " + f"{response.status_code} {response.text}" + ) + return True, False - # Then close the issue - issue_url = f"{self.base_url}/issues/{issue_number}" - with httpx.Client() as client: - response = client.patch( - issue_url, headers=self.headers, json={"state": "closed"} - ) - - return response.status_code == 200 + return True, True def find_label_application_date( @@ -371,9 +387,16 @@ def main(): "**If this was closed in error**, please leave a comment explaining the situation and we'll reopen it." ) - if client.close_issue(issue.number, close_message): - print(f"[SUCCESS] Closed issue #{issue.number} (needs MRE)") + closed, commented = client.close_issue(issue.number, close_message) + if closed: closed_count += 1 + if commented: + print(f"[SUCCESS] Closed issue #{issue.number} (needs MRE)") + else: + print( + f"[WARNING] Closed issue #{issue.number} but " + f"comment was not posted" + ) else: print(f"[ERROR] Failed to close issue #{issue.number}") diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py index a524b402c..3208b064a 100644 --- a/src/fastmcp/__init__.py +++ b/src/fastmcp/__init__.py @@ -10,6 +10,7 @@ from fastmcp.utilities.logging import configure_logging as _configure_logging if TYPE_CHECKING: from fastmcp.client import Client as Client + from fastmcp.apps.app import FastMCPApp as FastMCPApp settings = Settings() if settings.log_enabled: @@ -18,16 +19,15 @@ if settings.log_enabled: enable_rich_tracebacks=settings.enable_rich_tracebacks, ) +from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.server.server import FastMCP from fastmcp.server.context import Context import fastmcp.server __version__ = _version("fastmcp") - -# ensure deprecation warnings are displayed by default if settings.deprecation_warnings: - warnings.simplefilter("default", DeprecationWarning) + warnings.simplefilter("default", FastMCPDeprecationWarning) # --- Lazy imports for performance (see #3292) --- @@ -40,6 +40,10 @@ def __getattr__(name: str) -> object: from fastmcp.client import Client return Client + if name == "FastMCPApp": + from fastmcp.apps.app import FastMCPApp + + return FastMCPApp if name == "client": return importlib.import_module("fastmcp.client") raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -49,5 +53,7 @@ __all__ = [ "Client", "Context", "FastMCP", + "FastMCPApp", + "FastMCPDeprecationWarning", "settings", ] diff --git a/src/fastmcp/apps/__init__.py b/src/fastmcp/apps/__init__.py new file mode 100644 index 000000000..d8fc21696 --- /dev/null +++ b/src/fastmcp/apps/__init__.py @@ -0,0 +1,18 @@ +"""FastMCP Apps — interactive UIs for MCP tools. + +This package contains the app-related components: + +- ``FastMCPApp`` — composable provider for interactive apps with backend tools +- ``AppConfig`` — configuration for MCP App tools and resources +- ``ResourceCSP`` / ``ResourcePermissions`` — security configuration +""" + +from fastmcp.apps.app import FastMCPApp as FastMCPApp +from fastmcp.apps.config import AppConfig as AppConfig +from fastmcp.apps.config import PrefabAppConfig as PrefabAppConfig +from fastmcp.apps.config import ResourceCSP as ResourceCSP +from fastmcp.apps.config import ResourcePermissions as ResourcePermissions +from fastmcp.apps.config import UI_EXTENSION_ID as UI_EXTENSION_ID +from fastmcp.apps.config import app_config_to_meta_dict as app_config_to_meta_dict +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 diff --git a/src/fastmcp/apps/app.py b/src/fastmcp/apps/app.py new file mode 100644 index 000000000..f4fed7e15 --- /dev/null +++ b/src/fastmcp/apps/app.py @@ -0,0 +1,428 @@ +"""FastMCPApp — a Provider that represents a composable MCP application. + +FastMCPApp binds entry-point tools (model calls these) together with backend +tools (the UI calls these via CallTool). Backend tools are tagged with +``meta["fastmcp"]["app"]`` so they can be found through the provider chain +even when transforms (namespace, visibility, etc.) have renamed or hidden +them — the server sets a context var that tells ``Provider.get_tool`` to +fall back to a direct lookup for app-visible tools. + +Usage:: + + from fastmcp import FastMCP, FastMCPApp + + app = FastMCPApp("Dashboard") + + @app.ui() + def show_dashboard() -> Component: + return Column(...) + + @app.tool() + def save_contact(name: str, email: str) -> str: + return name + + server = FastMCP("Platform") + server.add_provider(app) +""" + +from __future__ import annotations + +import inspect +from collections.abc import AsyncIterator, Callable, Sequence +from contextlib import asynccontextmanager, suppress +from typing import Any, Literal, TypeVar, overload + +from mcp.types import AnyFunction, Icon, ToolAnnotations + +from fastmcp.server.auth.authorization import AuthCheck +from fastmcp.server.providers.base import Provider +from fastmcp.server.providers.local_provider import LocalProvider +from fastmcp.tools.base import Tool +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + +F = TypeVar("F", bound=Callable[..., Any]) + + +# --------------------------------------------------------------------------- +# CallTool resolver +# --------------------------------------------------------------------------- + + +def _make_resolver(app_name: str | None = None) -> Any: + """Create a CallTool resolver that prefixes tool names with the app name. + + When ``app_name`` is set, tool references like ``CallTool("store_files")`` + or ``CallTool(store_files)`` are resolved to + ``ResolvedTool(name="Files___store_files")``. This produces stable + identifiers that bypass transforms and work without host ``_meta`` + forwarding. + """ + + def _prefix(name: str) -> str: + if app_name and "___" not in name: + return f"{app_name}___{name}" + return name + + def _resolve_tool_ref(fn: Any) -> Any: + from prefab_ui.app import ResolvedTool + + if isinstance(fn, str): + return ResolvedTool(name=_prefix(fn)) + + fmeta: Any = None + try: + from fastmcp.decorators import get_fastmcp_meta + + fmeta = get_fastmcp_meta(fn) + except Exception: + pass + + if fmeta is not None: + name: str | None = getattr(fmeta, "name", None) + if name is not None: + return ResolvedTool(name=_prefix(name)) + + fn_name = getattr(fn, "__name__", None) + if fn_name is not None: + return ResolvedTool(name=_prefix(fn_name)) + + raise ValueError(f"Cannot resolve tool reference: {fn!r}") + + return _resolve_tool_ref + + +def _dispatch_decorator( + name_or_fn: str | AnyFunction | None, + name: str | None, + register: Callable[[Any, str | None], Any], + decorator_name: str, +) -> Any: + """Shared dispatch logic for @app.tool() and @app.ui() calling patterns.""" + if inspect.isroutine(name_or_fn): + return register(name_or_fn, name) + + if isinstance(name_or_fn, str): + if name is not None: + raise TypeError( + "Cannot specify both a name as first argument and as keyword argument." + ) + tool_name: str | None = name_or_fn + elif name_or_fn is None: + tool_name = name + else: + raise TypeError( + f"First argument to @{decorator_name} must be a function, string, or None, " + f"got {type(name_or_fn)}" + ) + + def decorator(fn: F) -> F: + return register(fn, tool_name) + + return decorator + + +# --------------------------------------------------------------------------- +# FastMCPApp +# --------------------------------------------------------------------------- + + +class FastMCPApp(Provider): + """A Provider that represents an MCP application. + + Binds together entry-point tools (``@app.ui``), backend tools + (``@app.tool``), and the Prefab renderer resource. Backend tools + are tagged with ``meta["fastmcp"]["app"]`` so ``Provider.get_tool`` + can find them by original name even when transforms have been applied. + """ + + def __init__(self, name: str) -> None: + if "___" in name: + raise ValueError( + f"App name {name!r} must not contain '___' " + "(reserved as the app tool routing separator)" + ) + super().__init__() + self.name = name + self._local = LocalProvider(on_duplicate="error") + + def __repr__(self) -> str: + return f"FastMCPApp({self.name!r})" + + # ------------------------------------------------------------------ + # @app.tool() — backend tools called by the UI + # ------------------------------------------------------------------ + + @overload + def tool( + self, + name_or_fn: F, + *, + name: str | None = None, + description: str | None = None, + model: bool = False, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> F: ... + + @overload + def tool( + self, + name_or_fn: str | None = None, + *, + name: str | None = None, + description: str | None = None, + model: bool = False, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> Callable[[F], F]: ... + + def tool( + self, + name_or_fn: str | AnyFunction | None = None, + *, + name: str | None = None, + description: str | None = None, + model: bool = False, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> Any: + """Register a backend tool that the UI calls via CallTool. + + Backend tools default to ``visibility=["app"]``. Pass ``model=True`` + to also expose the tool to the model (``visibility=["app", "model"]``). + + Supports multiple calling patterns:: + + @app.tool + def save(name: str): ... + + @app.tool() + def save(name: str): ... + + @app.tool("custom_name") + def save(name: str): ... + """ + visibility: list[Literal["app", "model"]] = ( + ["app", "model"] if model else ["app"] + ) + + def _register(fn: F, tool_name: str | None) -> F: + resolved_name = tool_name or getattr(fn, "__name__", None) + if resolved_name is None: + raise ValueError(f"Cannot determine tool name for {fn!r}") + + from fastmcp.apps.config import AppConfig, app_config_to_meta_dict + + app_config = AppConfig(visibility=visibility) + meta: dict[str, Any] = { + "ui": app_config_to_meta_dict(app_config), + "fastmcp": {"app": self.name}, + } + + tool_obj = Tool.from_function( + fn, + name=resolved_name, + description=description, + meta=meta, + timeout=timeout, + auth=auth, + ) + self._local._add_component(tool_obj) + return fn + + return _dispatch_decorator(name_or_fn, name, _register, "tool") + + # ------------------------------------------------------------------ + # @app.ui() — entry-point tools the model calls to open the app + # ------------------------------------------------------------------ + + @overload + def ui( + self, + name_or_fn: F, + *, + name: str | None = None, + description: str | None = None, + title: str | None = None, + tags: set[str] | None = None, + icons: list[Icon] | None = None, + annotations: ToolAnnotations | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> F: ... + + @overload + def ui( + self, + name_or_fn: str | None = None, + *, + name: str | None = None, + description: str | None = None, + title: str | None = None, + tags: set[str] | None = None, + icons: list[Icon] | None = None, + annotations: ToolAnnotations | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> Callable[[F], F]: ... + + def ui( + self, + name_or_fn: str | AnyFunction | None = None, + *, + name: str | None = None, + description: str | None = None, + title: str | None = None, + tags: set[str] | None = None, + icons: list[Icon] | None = None, + annotations: ToolAnnotations | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> Any: + """Register a UI entry-point tool that the model calls. + + Entry-point tools default to ``visibility=["model"]`` and auto-wire + the Prefab renderer resource and CSP. They are tagged with the app + name so structured content includes ``_meta.fastmcp.app``. + + Supports multiple calling patterns:: + + @app.ui + def dashboard() -> Component: ... + + @app.ui() + def dashboard() -> Component: ... + + @app.ui("my_dashboard") + def dashboard() -> Component: ... + """ + + def _register(fn: F, tool_name: str | None) -> F: + from fastmcp.apps.config import AppConfig, app_config_to_meta_dict + from fastmcp.server.providers.local_provider.decorators.tools import ( + PREFAB_RENDERER_URI, + _ensure_prefab_renderer, + ) + + try: + from prefab_ui.renderer import get_renderer_csp + + from fastmcp.apps.config import ResourceCSP + + csp = get_renderer_csp() + app_config = AppConfig( + resource_uri=PREFAB_RENDERER_URI, + visibility=["model"], + csp=ResourceCSP( + resource_domains=csp.get("resource_domains"), + connect_domains=csp.get("connect_domains"), + ), + ) + except ImportError: + app_config = AppConfig( + resource_uri=PREFAB_RENDERER_URI, + visibility=["model"], + ) + + meta: dict[str, Any] = { + "ui": app_config_to_meta_dict(app_config), + "fastmcp": {"app": self.name}, + } + + tool_obj = Tool.from_function( + fn, + name=tool_name, + description=description, + title=title, + tags=tags, + icons=icons, + annotations=annotations, + meta=meta, + timeout=timeout, + auth=auth, + ) + self._local._add_component(tool_obj) + + # Register the Prefab renderer resource on the internal provider + with suppress(ImportError): + _ensure_prefab_renderer(self._local) + + return fn + + return _dispatch_decorator(name_or_fn, name, _register, "ui") + + # ------------------------------------------------------------------ + # Programmatic tool addition + # ------------------------------------------------------------------ + + def add_tool( + self, + tool: Tool | Callable[..., Any], + ) -> Tool: + """Add a tool to this app programmatically. + + The tool is tagged with this app's name for routing. + """ + if not isinstance(tool, Tool): + tool = Tool._ensure_tool(tool) + + meta = dict(tool.meta) if tool.meta else {} + meta.setdefault("fastmcp", {})["app"] = self.name + ui = meta.setdefault("ui", {}) + if "visibility" not in ui: + ui["visibility"] = ["app"] + tool.meta = meta + + self._local._add_component(tool) + return tool + + # ------------------------------------------------------------------ + # Provider interface — delegate to internal LocalProvider + # ------------------------------------------------------------------ + + async def _list_tools(self) -> Sequence[Tool]: + return await self._local._list_tools() + + async def _get_tool(self, name: str, version: Any = None) -> Tool | None: + return await self._local._get_tool(name, version) + + async def _list_resources(self) -> Sequence[Any]: + return await self._local._list_resources() + + async def _get_resource(self, uri: str, version: Any = None) -> Any | None: + return await self._local._get_resource(uri, version) + + async def _list_resource_templates(self) -> Sequence[Any]: + return await self._local._list_resource_templates() + + async def _get_resource_template(self, uri: str, version: Any = None) -> Any | None: + return await self._local._get_resource_template(uri, version) + + async def _list_prompts(self) -> Sequence[Any]: + return await self._local._list_prompts() + + async def _get_prompt(self, name: str, version: Any = None) -> Any | None: + return await self._local._get_prompt(name, version) + + @asynccontextmanager + async def lifespan(self) -> AsyncIterator[None]: + async with self._local.lifespan(): + yield + + # ------------------------------------------------------------------ + # Convenience runner + # ------------------------------------------------------------------ + + def run( + self, + transport: Literal["stdio", "http", "sse", "streamable-http"] | None = None, + **kwargs: Any, + ) -> None: + """Create a temporary FastMCP server and run this app standalone.""" + from fastmcp.server.server import FastMCP + + server = FastMCP(self.name) + server.add_provider(self) + server.run(transport=transport, **kwargs) diff --git a/src/fastmcp/apps/approval.py b/src/fastmcp/apps/approval.py new file mode 100644 index 000000000..17b124e1f --- /dev/null +++ b/src/fastmcp/apps/approval.py @@ -0,0 +1,198 @@ +"""Approval — a Provider that adds human-in-the-loop approval to any server. + +The LLM presents a summary of what it's about to do, and the user +approves or rejects via buttons. The result is sent back into the +conversation as a message, prompting the LLM's next turn. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps.approval import Approval + + mcp = FastMCP("My Server") + mcp.add_provider(Approval()) +""" + +from __future__ import annotations + +from typing import Literal + +try: + from prefab_ui.actions import SetState + from prefab_ui.actions.mcp import SendMessage + from prefab_ui.app import PrefabApp + from prefab_ui.components import ( + H3, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + Column, + Muted, + Row, + Text, + ) + from prefab_ui.components.control_flow import If + from prefab_ui.rx import STATE +except ImportError as _exc: + raise ImportError( + "Approval requires prefab-ui. Install with: pip install 'fastmcp[apps]'" + ) from _exc + + +from fastmcp.apps.app import FastMCPApp + + +class Approval(FastMCPApp): + """A Provider that adds human-in-the-loop approval to a server. + + The LLM calls the ``request_approval`` tool with a summary and + optional details. The user sees an approval card with Approve and + Reject buttons. Clicking either sends a message back into the + conversation (via ``SendMessage``), triggering the LLM's next turn. + + The message appears as if the user sent it, so the LLM sees + something like ``'"Deploy v3.2 to production" is APPROVED'``. + + Example:: + + from fastmcp import FastMCP + from fastmcp.apps.approval import Approval + + mcp = FastMCP("My Server") + mcp.add_provider(Approval()) + + Customized:: + + Approval( + title="Deploy Gate", + approve_text="Ship it", + approve_variant="default", + reject_text="Abort", + reject_variant="destructive", + ) + """ + + def __init__( + self, + name: str = "Approval", + *, + title: str = "Approval Required", + approve_text: str = "Approve", + reject_text: str = "Reject", + approve_variant: Literal[ + "default", "destructive", "success", "info" + ] = "default", + reject_variant: Literal[ + "default", "outline", "destructive", "success", "info" + ] = "outline", + ) -> None: + super().__init__(name) + self._title = title + self._approve_text = approve_text + self._reject_text = reject_text + self._approve_variant = approve_variant + self._reject_variant = reject_variant + self._register_tools() + + def __repr__(self) -> str: + return f"Approval({self.name!r})" + + def _register_tools(self) -> None: + provider = self + + @self.ui() + def request_approval( + summary: str, + details: str | None = None, + title: str | None = None, + approve_text: str | None = None, + reject_text: str | None = None, + approve_variant: str | None = None, + reject_variant: str | None = None, + ) -> PrefabApp: + """Request human approval before proceeding with an action. + + Call this tool proactively whenever you are about to take a + significant or irreversible action and want the user to + confirm first. Do NOT wait for the user to ask you to seek + approval — use your judgment about when confirmation is + appropriate. + + The user will see an approval card with the summary, optional + details, and Approve/Reject buttons. When they click a button, + their decision appears as a message in the conversation (as if + the user typed it), like: + + "Deploy v3.2 to production" — I selected: Approve + + or: + + "Deploy v3.2 to production" — I selected: Reject + + IMPORTANT: After calling this tool, you MUST stop and wait + for the user's response. Do not continue, do not take any + other actions, do not generate further output until you see + the "I selected:" message. If approved, continue with the + action. If rejected, acknowledge and ask how to proceed. + + Args: + summary: Brief description of the action requiring approval + (shown prominently to the user). + details: Optional longer explanation, context, or + consequences of the action. + title: Heading for the approval card (default: "Approval Required"). + approve_text: Label for the approve button (default: "Approve"). + reject_text: Label for the reject button (default: "Reject"). + approve_variant: Button style — "default", "destructive", + "success", or "info". + reject_variant: Button style for the reject button + (same options plus "outline"). + """ + _title = title or provider._title + _approve = approve_text or provider._approve_text + _reject = reject_text or provider._reject_text + _approve_v = approve_variant or provider._approve_variant + _reject_v = reject_variant or provider._reject_variant + + approve_msg = f'"{summary}" — I selected: {_approve}' + reject_msg = f'"{summary}" — I selected: {_reject}' + + with Card(css_class="max-w-lg mx-auto") as view: + with CardHeader(): + H3(_title) + + with CardContent(), Column(gap=3): + Text(summary, css_class="font-medium") + if details: + Muted(details) + + with CardFooter(): + with If(STATE.decided): + Muted("Response sent.") + with If(~STATE.decided): # noqa: SIM117 + with Row(gap=2, css_class="w-full justify-end"): + Button( + _reject, + variant=_reject_v, + on_click=[ + SendMessage(reject_msg), + SetState("decided", True), + ], + ) + Button( + _approve, + variant=_approve_v, + on_click=[ + SendMessage(approve_msg), + SetState("decided", True), + ], + ) + + return PrefabApp( + view=view, + state={"decided": False}, + ) diff --git a/src/fastmcp/apps/choice.py b/src/fastmcp/apps/choice.py new file mode 100644 index 000000000..aaffef903 --- /dev/null +++ b/src/fastmcp/apps/choice.py @@ -0,0 +1,141 @@ +"""Choice — a Provider that lets the user pick from a set of options. + +The LLM presents options, the user clicks one, and the selection +flows back into the conversation as a message. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps.choice import Choice + + mcp = FastMCP("My Server") + mcp.add_provider(Choice()) +""" + +from __future__ import annotations + +from typing import Literal + +try: + from prefab_ui.actions import SetState + from prefab_ui.actions.mcp import SendMessage + from prefab_ui.app import PrefabApp + from prefab_ui.components import ( + H3, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + Column, + Muted, + Text, + ) + from prefab_ui.components.control_flow import If + from prefab_ui.rx import STATE +except ImportError as _exc: + raise ImportError( + "Choice requires prefab-ui. Install with: pip install 'fastmcp[apps]'" + ) from _exc + +from fastmcp.apps.app import FastMCPApp + + +class Choice(FastMCPApp): + """A Provider that lets the user choose from a set of options. + + The LLM calls ``choose`` with a prompt and a list of options. + The user sees a card with one button per option. Clicking a button + sends the selection back into the conversation via ``SendMessage``, + triggering the LLM's next turn. + + Example:: + + from fastmcp import FastMCP + from fastmcp.apps.choice import Choice + + mcp = FastMCP("My Server") + mcp.add_provider(Choice()) + """ + + def __init__( + self, + name: str = "Choice", + *, + title: str = "Choose an Option", + variant: Literal[ + "default", "outline", "destructive", "success", "info" + ] = "outline", + ) -> None: + super().__init__(name) + self._title = title + self._variant = variant + self._register_tools() + + def __repr__(self) -> str: + return f"Choice({self.name!r})" + + def _register_tools(self) -> None: + provider = self + + @self.ui() + def choose( + prompt: str, + options: list[str], + title: str | None = None, + ) -> PrefabApp: + """Present the user with a set of options to choose from. + + Call this tool when you need the user to make a decision + between discrete alternatives. Use it proactively — don't + ask the user to type their choice in chat when you can + present clean, clickable options instead. + + The user will see a card with one button per option. When + they click one, their choice appears as a message in the + conversation (as if the user typed it), like: + + "Which deployment strategy?" — I selected: Blue-green + + IMPORTANT: After calling this tool, you MUST stop and wait + for the user's response. Do not continue or take any other + actions until you see the "I selected:" message. + + Args: + prompt: The question or decision to present to the user. + options: List of options the user can choose from. + title: Optional heading for the card. + """ + _title = title or provider._title + + with Card(css_class="max-w-lg mx-auto") as view: + with CardHeader(): + H3(_title) + + with CardContent(): + Text(prompt, css_class="font-medium") + + with CardFooter(): + with If(STATE.decided): + Muted("Response sent.") + with If(~STATE.decided): # noqa: SIM117 + with Column(gap=2, css_class="w-full"): + for option in options: + Button( + option, + variant=provider._variant, + css_class="w-full justify-start", + on_click=[ + SendMessage( + f'"{prompt}" — I selected: {option}' + ), + SetState("decided", True), + ], + ) + + return PrefabApp( + view=view, + state={"decided": False}, + ) diff --git a/src/fastmcp/apps/config.py b/src/fastmcp/apps/config.py new file mode 100644 index 000000000..c55cd0b13 --- /dev/null +++ b/src/fastmcp/apps/config.py @@ -0,0 +1,177 @@ +"""MCP Apps support — extension negotiation and typed UI metadata models. + +Provides constants and Pydantic models for the MCP Apps extension +(io.modelcontextprotocol/ui), enabling tools and resources to carry +UI metadata for clients that support interactive app rendering. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +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 + +UI_EXTENSION_ID = "io.modelcontextprotocol/ui" + + +class ResourceCSP(BaseModel): + """Content Security Policy for MCP App resources. + + Declares which external origins the app is allowed to connect to or + load resources from. Hosts use these declarations to build the + ``Content-Security-Policy`` header for the sandboxed iframe. + """ + + connect_domains: list[str] | None = Field( + default=None, + alias="connectDomains", + description="Origins allowed for fetch/XHR/WebSocket (connect-src)", + ) + resource_domains: list[str] | None = Field( + default=None, + alias="resourceDomains", + description="Origins allowed for scripts, images, styles, fonts (script-src etc.)", + ) + frame_domains: list[str] | None = Field( + default=None, + alias="frameDomains", + description="Origins allowed for nested iframes (frame-src)", + ) + base_uri_domains: list[str] | None = Field( + default=None, + alias="baseUriDomains", + description="Allowed base URIs for the document (base-uri)", + ) + + model_config = {"populate_by_name": True, "extra": "allow"} + + +class ResourcePermissions(BaseModel): + """Iframe sandbox permissions for MCP App resources. + + Each field, when set (typically to ``{}``), requests that the host + grant the corresponding Permission Policy feature to the sandboxed + iframe. Hosts MAY honour these; apps should use JS feature detection + as a fallback. + """ + + camera: dict[str, Any] | None = Field( + default=None, description="Request camera access" + ) + microphone: dict[str, Any] | None = Field( + default=None, description="Request microphone access" + ) + geolocation: dict[str, Any] | None = Field( + default=None, description="Request geolocation access" + ) + clipboard_write: dict[str, Any] | None = Field( + default=None, + alias="clipboardWrite", + description="Request clipboard-write access", + ) + + model_config = {"populate_by_name": True, "extra": "allow"} + + +class AppConfig(BaseModel): + """Configuration for MCP App tools and resources. + + Controls how a tool or resource participates in the MCP Apps extension. + On tools, ``resource_uri`` and ``visibility`` specify which UI resource + to render and where the tool appears. On resources, those fields must + be left unset (the resource itself is the UI). + + All fields use ``exclude_none`` serialization so only explicitly-set + values appear on the wire. Aliases match the MCP Apps wire format + (camelCase). + """ + + resource_uri: str | None = Field( + default=None, + alias="resourceUri", + description="URI of the UI resource (typically ui:// scheme). Tools only.", + ) + visibility: list[Literal["app", "model"]] | None = Field( + default=None, + description="Where this tool is visible: 'app', 'model', or both. Tools only.", + ) + csp: ResourceCSP | None = Field( + default=None, description="Content Security Policy for the app iframe" + ) + permissions: ResourcePermissions | None = Field( + default=None, description="Iframe sandbox permissions" + ) + domain: str | None = Field(default=None, description="Domain for the iframe") + prefers_border: bool | None = Field( + default=None, + alias="prefersBorder", + description="Whether the UI prefers a visible border", + ) + + model_config = {"populate_by_name": True, "extra": "allow"} + + +class PrefabAppConfig(AppConfig): + """App configuration for Prefab tools with sensible defaults. + + Like ``app=True`` but customizable. Auto-wires the Prefab renderer + URI and merges the renderer's CSP with any additional domains you + specify. The renderer resource is registered automatically. + + Example:: + + @mcp.tool(app=PrefabAppConfig()) # same as app=True + + @mcp.tool(app=PrefabAppConfig( + csp=ResourceCSP(frame_domains=["https://example.com"]), + )) + """ + + def model_post_init(self, __context: Any) -> None: + # Set the renderer URI if not explicitly overridden + if self.resource_uri is None: + self.resource_uri = "ui://prefab/renderer.html" + + # Merge renderer CSP with user-provided CSP + try: + from prefab_ui.renderer import get_renderer_csp + + renderer_csp = get_renderer_csp() + except ImportError: + renderer_csp = {} + + if renderer_csp: + user_csp = self.csp or ResourceCSP() + # Start from the user's CSP (preserves model_extra for + # forward-compat directives), then merge renderer domains. + merged_data = user_csp.model_dump(exclude_none=True) + merged_data["connect_domains"] = _merge_domains( + renderer_csp.get("connect_domains"), + user_csp.connect_domains, + ) + merged_data["resource_domains"] = _merge_domains( + renderer_csp.get("resource_domains"), + user_csp.resource_domains, + ) + self.csp = ResourceCSP(**merged_data) + + +def _merge_domains(base: list[str] | None, extra: list[str] | None) -> list[str] | None: + """Merge two domain lists, deduplicating.""" + if base is None and extra is None: + return None + combined = list(base or []) + for d in extra or []: + if d not in combined: + combined.append(d) + return combined or None + + +def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]: + """Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``.""" + if isinstance(app, AppConfig): + return app.model_dump(by_alias=True, exclude_none=True) + return app diff --git a/src/fastmcp/apps/file_upload.py b/src/fastmcp/apps/file_upload.py new file mode 100644 index 000000000..8cc91e039 --- /dev/null +++ b/src/fastmcp/apps/file_upload.py @@ -0,0 +1,393 @@ +"""FileUpload — a Provider that adds drag-and-drop file upload to any server. + +Lets users upload files directly to the server through an interactive UI, +bypassing the LLM context window entirely. The LLM can then read and work +with uploaded files through model-visible tools. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps import FileUpload + + mcp = FastMCP("My Server") + mcp.add_provider(FileUpload()) + +For custom persistence, override the storage methods:: + + class S3Upload(FileUpload): + def on_store(self, files, ctx): + # write to S3, return summaries + ... + + def on_list(self, ctx): + # list from S3 + ... + + def on_read(self, name, ctx): + # read from S3 + ... +""" + +from __future__ import annotations + +try: + from prefab_ui.actions import SetState, ShowToast + from prefab_ui.actions.mcp import CallTool + from prefab_ui.app import PrefabApp + from prefab_ui.components import ( + H3, + Badge, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + Column, + DropZone, + Muted, + Row, + Separator, + Small, + Text, + ) + from prefab_ui.components.control_flow import Else, ForEach, If + from prefab_ui.rx import ERROR, RESULT, STATE, Rx +except ImportError as _exc: + raise ImportError( + "FileUpload requires prefab-ui. Install with: pip install 'fastmcp[apps]'" + ) from _exc + +import base64 +from datetime import datetime +from typing import Any + +from fastmcp.apps.app import FastMCPApp +from fastmcp.server.context import Context + +_TEXT_EXTENSIONS = frozenset( + (".csv", ".json", ".txt", ".md", ".py", ".yaml", ".yml", ".toml") +) + + +def _format_size(size: int) -> str: + if size < 1024: + return f"{size} B" + elif size < 1024 * 1024: + return f"{size / 1024:.1f} KB" + else: + return f"{size / (1024 * 1024):.1f} MB" + + +def _make_summary(entry: dict[str, Any]) -> dict[str, Any]: + return { + "name": entry["name"], + "type": entry["type"], + "size": entry["size"], + "size_display": _format_size(entry["size"]), + "uploaded_at": entry["uploaded_at"], + } + + +class FileUpload(FastMCPApp): + """A Provider that adds file upload capabilities to a server. + + Registers a drag-and-drop UI tool, a backend storage tool, and + model-visible tools for listing and reading uploaded files. + + Files are scoped by MCP session and stored in memory by default. + Override ``on_store``, ``on_list``, and ``on_read`` for custom + persistence (filesystem, S3, database, etc.). Each method receives + the current ``Context``, giving access to session ID, auth tokens, + and request metadata for partitioning and authorization. + + **Session scoping:** The default storage uses ``ctx.session_id`` to + isolate files by session. This works with stdio, SSE, and stateful + HTTP transports. In **stateless HTTP** mode, each request creates a + new session, so files won't persist across requests. For stateless + deployments, override the storage methods to partition by a stable + identifier from the auth context:: + + class UserScopedUpload(FileUpload): + def on_store(self, files, ctx): + user_id = ctx.access_token["sub"] + ... + + Example:: + + from fastmcp import FastMCP + from fastmcp.apps.file_upload import FileUpload + + mcp = FastMCP("My Server") + mcp.add_provider(FileUpload()) + """ + + def __init__( + self, + name: str = "Files", + *, + max_file_size: int = 10 * 1024 * 1024, + title: str = "File Upload", + description: str = ( + "Drop files to upload them to the server. " + "The model can then read and analyze them " + "without using the context window." + ), + drop_label: str = "Drop files here", + ) -> None: + super().__init__(name) + self._max_file_size = max_file_size + self._title = title + self._description = description + self._drop_label = drop_label + + # Default in-memory store, keyed by session_id + self._store: dict[str, dict[str, dict[str, Any]]] = {} + + self._register_tools() + + def __repr__(self) -> str: + return f"FileUpload({self.name!r})" + + # ------------------------------------------------------------------ + # Storage interface — override these for custom persistence + # ------------------------------------------------------------------ + + def _get_scope_key(self, ctx: Context) -> str: + """Return the key used to partition file storage. + + Defaults to ``ctx.session_id``, which is stable for stdio, SSE, + and stateful HTTP. The default ``on_store``/``on_list``/``on_read`` + implementations call this to partition the in-memory store. + + Override to scope by user, tenant, or any other dimension:: + + def _get_scope_key(self, ctx): + return ctx.access_token["sub"] + """ + try: + return ctx.session_id + except RuntimeError: + return "__default__" + + def on_store( + self, + files: list[dict[str, Any]], + ctx: Context, + ) -> list[dict[str, Any]]: + """Store uploaded files and return summaries. + + Args: + files: List of file dicts, each with ``name``, ``size``, + ``type``, and ``data`` (base64-encoded content). + ctx: The current request context. Use for session ID, + auth tokens, or any metadata needed for partitioning. + + Override this method for custom persistence. The default + implementation stores files in memory, scoped by + ``_get_scope_key(ctx)``. + + Returns: + List of file summary dicts (``name``, ``type``, ``size``, + ``size_display``, ``uploaded_at``). + """ + scope = self._get_scope_key(ctx) + session_files = self._store.setdefault(scope, {}) + for f in files: + session_files[f["name"]] = { + "name": f["name"], + "size": f["size"], + "type": f["type"], + "data": f["data"], + "uploaded_at": datetime.now().isoformat(timespec="seconds"), + } + return [_make_summary(e) for e in session_files.values()] + + def on_list(self, ctx: Context) -> list[dict[str, Any]]: + """List all stored files. + + Args: + ctx: The current request context. + + Override this method for custom persistence. The default + implementation returns files from the current scope. + + Returns: + List of file summary dicts. + """ + scope = self._get_scope_key(ctx) + session_files = self._store.get(scope, {}) + return [_make_summary(e) for e in session_files.values()] + + def on_read(self, name: str, ctx: Context) -> dict[str, Any]: + """Read a file's contents by name. + + Args: + name: The filename to read. + ctx: The current request context. + + Override this method for custom persistence. The default + implementation reads from the current scope's in-memory store. + Text files are decoded from base64; binary files return a + truncated base64 preview. + + Returns: + Dict with file metadata and ``content`` (text) or + ``content_base64`` (binary preview). + + Raises: + ValueError: If the file is not found. + """ + scope = self._get_scope_key(ctx) + session_files = self._store.get(scope, {}) + if name not in session_files: + available = list(session_files.keys()) + raise ValueError(f"File {name!r} not found. Available: {available}") + entry = session_files[name] + result: dict[str, Any] = { + "name": entry["name"], + "size": entry["size"], + "type": entry["type"], + "uploaded_at": entry["uploaded_at"], + } + is_text = entry["type"].startswith("text/") or any( + entry["name"].endswith(ext) for ext in _TEXT_EXTENSIONS + ) + if is_text: + try: + result["content"] = base64.b64decode(entry["data"]).decode("utf-8") + except UnicodeDecodeError: + result["content_base64"] = entry["data"][:200] + "..." + else: + result["content_base64"] = entry["data"][:200] + "..." + return result + + # ------------------------------------------------------------------ + # Tool registration + # ------------------------------------------------------------------ + + def _register_tools(self) -> None: + provider = self + + @self.tool() + def store_files(files: list[dict], ctx: Context) -> list[dict]: + """Store uploaded files. Receives file objects with name, size, type, data (base64).""" + for f in files: + if f.get("size", 0) > provider._max_file_size: + raise ValueError( + f"File {f.get('name', '?')!r} exceeds max size " + f"({_format_size(f['size'])} > " + f"{_format_size(provider._max_file_size)})" + ) + return provider.on_store(files, ctx) + + @self.tool(model=True) + def list_files(ctx: Context) -> list[dict]: + """List all uploaded files with metadata.""" + return provider.on_list(ctx) + + @self.tool(model=True) + def read_file(name: str, ctx: Context) -> dict: + """Read an uploaded file's contents by name.""" + return provider.on_read(name, ctx) + + @self.ui() + def file_manager(ctx: Context) -> PrefabApp: + """Upload and manage files. Drop files here to send them to the server.""" + with Card(css_class="max-w-2xl mx-auto") as view: + with CardHeader(), Row(gap=2, align="center"): + H3(provider._title) + with If(STATE.stored.length()): + Badge( + STATE.stored.length(), # ty:ignore[invalid-argument-type] + variant="secondary", + ) + + with CardContent(), Column(gap=4): + Muted(provider._description) + + DropZone( + name="pending", + icon="inbox", + label=provider._drop_label, + description=( + "Any file type, up to " + f"{_format_size(provider._max_file_size)}" + ), + multiple=True, + max_size=provider._max_file_size, + ) + + with If(STATE.pending.length()), Column(gap=2): + with ( + ForEach("pending"), + Row(gap=2, align="center"), + Column(gap=0), + ): + Small(Rx("$item.name")) # ty:ignore[invalid-argument-type] + Muted(Rx("$item.type")) # ty:ignore[invalid-argument-type] + + Button( + "Upload to Server", + on_click=CallTool( + "store_files", + arguments={ + "files": Rx("pending"), + }, + on_success=[ + SetState("stored", RESULT), + SetState("pending", []), + ShowToast( + "Files uploaded!", + variant="success", + ), + ], + on_error=ShowToast( + ERROR, # ty:ignore[invalid-argument-type] + variant="error", + ), + ), + ) + + with If(STATE.stored.length()): + Separator() + Text( + "Uploaded", + css_class="font-medium text-sm", + ) + with ( + ForEach("stored") as f, + Row( + gap=2, + align="center", + css_class="justify-between", + ), + ): + with Column(gap=0): + Small(f.name) # ty:ignore[invalid-argument-type] + Muted(f.uploaded_at) # ty:ignore[invalid-argument-type] + with Row(gap=2): + Badge(f.type, variant="secondary") # ty:ignore[invalid-argument-type] + Badge( + f.size_display, # ty:ignore[invalid-argument-type] + variant="outline", + ) + + with CardFooter(), Row(align="center", css_class="w-full"): + with If(STATE.stored.length()): + Muted( + f"{STATE.stored.length()}" + f" {STATE.stored.length().pluralize('file')}" + " on server" + ) + with Else(): + Muted("No files uploaded yet") + + return PrefabApp( + view=view, + state={ + "pending": [], + "stored": provider.on_list(ctx), + }, + ) diff --git a/src/fastmcp/apps/form.py b/src/fastmcp/apps/form.py new file mode 100644 index 000000000..1d6f67b5a --- /dev/null +++ b/src/fastmcp/apps/form.py @@ -0,0 +1,184 @@ +"""FormInput — a Provider that collects structured input from the user. + +Define a Pydantic model for the data you need, and ``FormInput`` +generates a form UI. The user fills it out, the submission is +validated, and an optional callback processes the result. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from pydantic import BaseModel + from fastmcp import FastMCP + from fastmcp.apps.form import FormInput + + class ShippingAddress(BaseModel): + street: str + city: str + state: str + zip_code: str + + mcp = FastMCP("My Server") + mcp.add_provider(FormInput(model=ShippingAddress)) +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +try: + from prefab_ui.actions import SetState + from prefab_ui.actions.mcp import CallTool, SendMessage + from prefab_ui.app import PrefabApp + from prefab_ui.components import ( + H3, + Card, + CardContent, + CardFooter, + CardHeader, + Column, + Form, + Muted, + ) + from prefab_ui.components.control_flow import If + from prefab_ui.rx import RESULT, STATE +except ImportError as _exc: + raise ImportError( + "FormInput requires prefab-ui. Install with: pip install 'fastmcp[apps]'" + ) from _exc + +import pydantic + +from fastmcp.apps.app import FastMCPApp + + +class FormInput(FastMCPApp): + """A Provider that collects structured input via a Pydantic model. + + Define a model for the data you need, and ``FormInput`` generates + a form from it using ``Form.from_model()``. Field types, labels, + descriptions, and validation are all derived from the model. + + Optionally provide an ``on_submit`` callback to process the + validated data. The callback receives a model instance and returns + a string that goes back to the LLM. Without a callback, the + validated JSON is sent directly. + + Example:: + + from pydantic import BaseModel + from fastmcp import FastMCP + from fastmcp.apps.form import FormInput + + class Contact(BaseModel): + name: str + email: str + + mcp = FastMCP("My Server") + mcp.add_provider(FormInput(model=Contact)) + + With a callback:: + + def save_contact(contact: Contact) -> str: + db.insert(contact.model_dump()) + return f"Saved {contact.name}" + + mcp.add_provider(FormInput(model=Contact, on_submit=save_contact)) + """ + + def __init__( + self, + model: type[pydantic.BaseModel], + *, + name: str | None = None, + title: str | None = None, + submit_text: str = "Submit", + tool_name: str | None = None, + on_submit: Callable[..., str] | None = None, + send_message: bool = False, + ) -> None: + app_name = name or model.__name__ + super().__init__(app_name) + self._model = model + self._title = title or model.__name__ + self._submit_text = submit_text + self._tool_name = tool_name or f"collect_{model.__name__.lower()}" + self._on_submit = on_submit + self._send_message = send_message + self._register_tools() + + def __repr__(self) -> str: + return f"FormInput({self._model.__name__!r})" + + def _register_tools(self) -> None: + provider = self + model = self._model + + @self.tool() + def submit_form(data: dict[str, Any]) -> str: + """Validate and process form submission.""" + validated = model.model_validate(data) + if provider._on_submit is not None: + return provider._on_submit(validated) + return json.dumps(validated.model_dump(mode="json")) + + @self.ui( + name=provider._tool_name, + description=( + f"Collect {model.__name__} information from the user via a form. " + f"Call this tool when you need the user to provide " + f"{model.__name__} data. The user will see a validated form. " + f"After calling this tool, STOP and wait for the user to submit." + ), + ) + def collect_input( + prompt: str, + title: str | None = None, + submit_text: str | None = None, + ) -> PrefabApp: + """Collect structured input from the user. + + Args: + prompt: Tell the user what you need and why. + title: Optional heading for the form card. + submit_text: Optional label for the submit button. + """ + _title = title or provider._title + _submit = submit_text or provider._submit_text + + with Card(css_class="max-w-lg mx-auto") as view: + with CardHeader(): + H3(_title) + + with CardContent(), Column(gap=4): + Muted(prompt) + + on_success_actions: list[Any] = [ + SetState("submitted", True), + ] + if provider._send_message: + on_success_actions.insert( + 0, + SendMessage(RESULT), # ty:ignore[invalid-argument-type] + ) + + Form.from_model( + model, + submit_label=_submit, + on_submit=[ + CallTool( + "submit_form", + on_success=on_success_actions, + ), + ], + ) + + with CardFooter(), If(STATE.submitted): + Muted("Submitted.") + + return PrefabApp( + view=view, + state={"submitted": False}, + ) diff --git a/src/fastmcp/apps/generative.py b/src/fastmcp/apps/generative.py new file mode 100644 index 000000000..b3cbe3c33 --- /dev/null +++ b/src/fastmcp/apps/generative.py @@ -0,0 +1,199 @@ +"""GenerativeUI — a Provider that adds LLM-generated UI capabilities. + +Registers tools and resources from ``prefab_ui.generative`` so that an +LLM can write Prefab Python code, execute it in a sandbox, and render +the result as a streaming interactive UI. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps.generative import GenerativeUI + + mcp = FastMCP("My Server") + mcp.add_provider(GenerativeUI()) +""" + +try: + import prefab_ui.generative as _gen + from prefab_ui.renderer import ( + get_generative_renderer_csp, + get_generative_renderer_html, + ) +except ImportError as _exc: + raise ImportError( + "GenerativeUI requires prefab-ui. Install with: pip install 'fastmcp[apps]'" + ) from _exc + +import json +from collections.abc import AsyncIterator, Sequence +from contextlib import asynccontextmanager +from typing import Any + +from fastmcp.apps.config import AppConfig, ResourceCSP, app_config_to_meta_dict +from fastmcp.server.providers.base import Provider +from fastmcp.server.providers.local_provider import LocalProvider +from fastmcp.tools.base import Tool +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mime import UI_MIME_TYPE + +logger = get_logger(__name__) + + +def _build_csp() -> ResourceCSP: + """Build CSP from the generative renderer's declared requirements.""" + csp = get_generative_renderer_csp() + return ResourceCSP( + resource_domains=csp.get("resource_domains"), + connect_domains=csp.get("connect_domains"), + ) + + +class GenerativeUI(Provider): + """A Provider that adds generative UI capabilities to a server. + + Registers: + + - A ``generate_ui`` tool that accepts Prefab Python code, executes + it in a Pyodide sandbox, and returns the rendered PrefabApp. + Supports streaming via ``ontoolinputpartial``. + - A ``components`` tool that searches the Prefab component library. + - The generative renderer resource with CSP for Pyodide CDN access. + + Example:: + + from fastmcp import FastMCP + from fastmcp.apps.generative import GenerativeUI + + mcp = FastMCP("My Server") + mcp.add_provider(GenerativeUI()) + """ + + def __init__( + self, + *, + tool_name: str = "generate_prefab_ui", + include_components_tool: bool = True, + components_tool_name: str = "search_prefab_components", + ) -> None: + super().__init__() + self._tool_name = tool_name + self._components_tool_name = components_tool_name + self._include_components_tool = include_components_tool + self._local = LocalProvider(on_duplicate="error") + self._sandbox: Any = None + self._setup_done = False + + def __repr__(self) -> str: + return f"GenerativeUI(tool_name={self._tool_name!r})" + + def _get_sandbox(self) -> Any: + """Lazily create the Pyodide sandbox.""" + if self._sandbox is None: + from prefab_ui.sandbox import Sandbox + + self._sandbox = Sandbox() + return self._sandbox + + def _ensure_setup(self) -> None: + """Lazily register tools and resources on first access.""" + if self._setup_done: + return + + csp = _build_csp() + app_config = AppConfig(resource_uri=_gen.RESOURCE_URI, csp=csp) + + # -- generate_ui tool -- + # Wraps prefab_ui.generative.execute with sandbox lifecycle management. + + from prefab_ui.app import PrefabApp + + sandbox_ref = self # capture for closure + + async def generate_ui( + code: str, + data: str | dict[str, Any] | None = None, + ) -> PrefabApp: + parsed_data: dict[str, Any] | None + if isinstance(data, str): + parsed_data = json.loads(data) if data.strip() else None + else: + parsed_data = data + return await _gen.execute( + code, + data=parsed_data, + sandbox=sandbox_ref._get_sandbox(), + ) + + tool = Tool.from_function( + generate_ui, + name=self._tool_name, + description=_gen.execute.__doc__ or "", + meta={"ui": app_config_to_meta_dict(app_config)}, + ) + self._local._add_component(tool) + + # -- components tool -- + + if self._include_components_tool: + components_tool = Tool.from_function( + _gen.search_components, + name=self._components_tool_name, + description=_gen.search_components.__doc__ or "", + ) + self._local._add_component(components_tool) + + # -- generative renderer resource -- + + from fastmcp.resources.types import TextResource + + resource_config = AppConfig(csp=csp) + resource = TextResource( + uri=_gen.RESOURCE_URI, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + name="Prefab Generative Renderer", + text=get_generative_renderer_html(), + mime_type=UI_MIME_TYPE, + meta={"ui": app_config_to_meta_dict(resource_config)}, + ) + self._local._add_component(resource) + + self._setup_done = True + + # ------------------------------------------------------------------ + # Provider interface + # ------------------------------------------------------------------ + + async def _list_tools(self) -> Sequence[Tool]: + self._ensure_setup() + return await self._local._list_tools() + + async def _get_tool(self, name: str, version: Any = None) -> Tool | None: + self._ensure_setup() + return await self._local._get_tool(name, version) + + async def _list_resources(self) -> Sequence[Any]: + self._ensure_setup() + return await self._local._list_resources() + + async def _get_resource(self, uri: str, version: Any = None) -> Any | None: + self._ensure_setup() + return await self._local._get_resource(uri, version) + + async def _list_resource_templates(self) -> Sequence[Any]: + return [] + + async def _get_resource_template(self, uri: str, version: Any = None) -> Any | None: + return None + + async def _list_prompts(self) -> Sequence[Any]: + return [] + + async def _get_prompt(self, name: str, version: Any = None) -> Any | None: + return None + + @asynccontextmanager + async def lifespan(self) -> AsyncIterator[None]: + self._ensure_setup() + async with self._local.lifespan(): + yield diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py new file mode 100644 index 000000000..9a3d7be2d --- /dev/null +++ b/src/fastmcp/cli/apps_dev.py @@ -0,0 +1,1806 @@ +"""Dev server for previewing FastMCPApp UIs locally. + +Starts the user's MCP server on a configurable port, then starts a lightweight +Starlette dev server that: + + - Serves a Prefab-based tool picker at GET / + - Proxies /mcp to the user's server (avoids browser CORS restrictions) + - Serves the AppBridge host page at GET /launch + +The host page uses @modelcontextprotocol/ext-apps to connect to the MCP server +and render the selected UI tool inside an iframe. + +Startup sequence +---------------- +1. Download ext-apps app-bridge.js from npm and patch its bare + ``@modelcontextprotocol/sdk/…`` imports to use concrete esm.sh URLs. +2. Detect the exact Zod v4 module URL that esm.sh serves for that SDK version + and build an import-map entry that redirects the broken ``v4.mjs`` (which + only re-exports ``{z, default}``) to ``v4/classic/index.mjs`` (which + correctly exports every named Zod v4 function). Import maps apply to the + full module graph in the document, including cross-origin esm.sh modules. +3. Serve both the patched JS and the import-map JSON from the dev server. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import io +import json +import logging +import os +import re +import signal +import sys +import tarfile +import tempfile +import time +import urllib.request +import webbrowser +from pathlib import Path +from typing import Any +from urllib.parse import quote + +import httpcore +import httpx +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import HTMLResponse, Response, StreamingResponse +from starlette.routing import Route + +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# MCP message log (captures proxy traffic for the dev UI log panel) +# --------------------------------------------------------------------------- + + +class _MessageLog: + """In-memory buffer of MCP JSON-RPC messages flowing through the proxy.""" + + def __init__(self) -> None: + self._entries: list[dict[str, Any]] = [] + self._counter = 0 + self._request_methods: dict[int | str, str] = {} + self._request_times: dict[int | str, float] = {} + + def log_request(self, body: dict[str, Any]) -> None: + method = body.get("method", "unknown") + jsonrpc_id = body.get("id") + timestamp = time.time() + if jsonrpc_id is not None: + self._request_methods[jsonrpc_id] = method + self._request_times[jsonrpc_id] = timestamp + self._counter += 1 + self._entries.append( + { + "id": self._counter, + "timestamp": timestamp, + "direction": "request", + "method": method, + "body": body, + } + ) + + def log_response(self, body: dict[str, Any]) -> None: + # Server-initiated notifications have "method" but no "id" + if "method" in body and "id" not in body: + self._counter += 1 + self._entries.append( + { + "id": self._counter, + "timestamp": time.time(), + "direction": "notification", + "method": body.get("method", "unknown"), + "body": body, + } + ) + return + + jsonrpc_id = body.get("id") + method = ( + self._request_methods.pop(jsonrpc_id, None) + if jsonrpc_id is not None + else None + ) + request_time = ( + self._request_times.pop(jsonrpc_id, None) + if jsonrpc_id is not None + else None + ) + timestamp = time.time() + duration_ms = ( + round((timestamp - request_time) * 1000, 1) if request_time else None + ) + self._counter += 1 + self._entries.append( + { + "id": self._counter, + "timestamp": timestamp, + "direction": "response", + "method": method, + "body": body, + "duration_ms": duration_ms, + } + ) + + def get_since(self, since_id: int = 0) -> list[dict[str, Any]]: + return [e for e in self._entries if e["id"] > since_id] + + def log_bridge(self, body: dict[str, Any]) -> None: + method = body.get("method", "unknown") + self._counter += 1 + self._entries.append( + { + "id": self._counter, + "timestamp": time.time(), + "direction": "bridge", + "method": method, + "body": body, + } + ) + + def clear(self) -> None: + self._entries.clear() + self._request_methods.clear() + self._request_times.clear() + + +def _log_response_bytes(log: _MessageLog, raw: bytes, content_type: str) -> None: + """Parse accumulated proxy response bytes and log as message entries.""" + if not raw: + return + try: + if "text/event-stream" in content_type: + for line in raw.decode("utf-8", errors="replace").splitlines(): + if line.startswith("data: "): + with contextlib.suppress(json.JSONDecodeError): + log.log_response(json.loads(line[6:])) + else: + body = json.loads(raw) + if isinstance(body, list): + for item in body: + log.log_response(item) + else: + log.log_response(body) + except (json.JSONDecodeError, TypeError): + pass + + +_EXT_APPS_VERSION = "1.0.1" +# Pin to the SDK version ext-apps 1.0.1 was compiled against so the client +# and transport modules are API-compatible with the app-bridge internals. +_MCP_SDK_VERSION = "1.25.2" + +# --------------------------------------------------------------------------- +# Shared AppBridge host shell +# --------------------------------------------------------------------------- + +# Both the picker and the app launcher use the same host-page structure: an +# iframe that hosts a Prefab renderer, wired to the MCP server via AppBridge. +# The only differences are (a) which URL loads in the iframe and (b) what +# oninitialized does. +# +# app-bridge.js is served locally (see _fetch_app_bridge_bundle). +# Client/Transport are loaded from esm.sh. +# The import map (injected as {import_map_tag}) patches the broken esm.sh +# Zod v4 module so all Zod named exports are visible to the SDK at runtime. + +_HOST_SHELL = """\ + + + + + {title} +{import_map_tag} + + + +
{status_text}
+ + + + +""" + +# --------------------------------------------------------------------------- +# Host page HTML +# --------------------------------------------------------------------------- + +_HOST_HTML_TEMPLATE = """\ + + + + + FastMCP Dev — {tool_name} +{import_map_tag} + + + +
Launching {tool_name}…
+ + + + +""" + +# --------------------------------------------------------------------------- +# Dev log panel (injected into host pages) +# --------------------------------------------------------------------------- + +_LOG_PANEL_HTML = """\ + + + + +""" + + +def _inject_log_panel(html: str) -> str: + """Inject the MCP message log panel before .""" + return html.replace("", _LOG_PANEL_HTML + "\n") + + +# --------------------------------------------------------------------------- +# Picker UI (Prefab-based, built in Python) +# --------------------------------------------------------------------------- + + +def _has_ui_resource(tool: dict[str, Any]) -> bool: + """Return True if the tool has a UI resourceUri in its metadata.""" + for key in ("meta", "_meta"): + m = tool.get(key) + if isinstance(m, dict): + ui = m.get("ui") + if isinstance(ui, dict) and ui.get("resourceUri"): + return True + return False + + +def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any]: + """Dynamically create a Pydantic model from a JSON Schema for form generation.""" + import pydantic + import pydantic.fields + + properties: dict[str, Any] = input_schema.get("properties") or {} + required: list[str] = input_schema.get("required") or [] + + field_definitions: dict[str, Any] = {} + for prop_name, prop in properties.items(): + json_type = prop.get("type", "string") + + # Handle anyOf / oneOf (union types like str | dict | None) + for key in ("anyOf", "oneOf"): + if key in prop: + non_null = [ + t + for t in prop[key] + if isinstance(t, dict) and t.get("type") != "null" + ] + if non_null: + types = [t.get("type") for t in non_null if "type" in t] + # Prefer object/array (need textarea for JSON editing), + # then string (most versatile text input), then scalars. + for candidate in ( + "object", + "array", + "string", + "integer", + "number", + "boolean", + ): + if candidate in types: + json_type = candidate + break + break + + match json_type: + case "integer": + py_type: type = int + case "number": + py_type = float + case "boolean": + py_type = bool + case "object" | "array": + # Render as a string textarea; api_launch parses JSON later + py_type = str + case _: + py_type = str + + title = prop.get("title") or prop_name.replace("_", " ").title() + description = prop.get("description") + is_required = prop_name in required + if is_required: + default = pydantic.fields.PydanticUndefined + elif "default" in prop: + default = prop["default"] + else: + default = None + py_type = py_type | None # type: ignore[assignment] # ty:ignore[invalid-assignment] + + extra: dict[str, Any] = {} + if prop.get("enum"): + from typing import Literal + + py_type = Literal[tuple(prop["enum"])] # type: ignore[assignment] # ty:ignore[invalid-type-form] + + # Textarea detection: + # 1. Explicit format: "textarea" in JSON schema + # 2. UI annotation: {"ui": {"type": "textarea"}} (json_schema_extra merged flat) + # 3. Object/array types need multiline JSON editing + use_textarea = ( + prop.get("format") == "textarea" + or ( + isinstance(prop.get("ui"), dict) + and prop["ui"].get("type") == "textarea" + ) + or json_type in ("object", "array") + ) + if use_textarea: + extra["json_schema_extra"] = {"ui": {"type": "textarea"}} + + field_definitions[prop_name] = ( + py_type, + pydantic.Field( + default=default, title=title, description=description, **extra + ), + ) + + return pydantic.create_model(f"{tool_name.title()}Form", **field_definitions) + + +def _build_picker_html(tools: list[dict[str, Any]]) -> str: + """Build Prefab picker page: dropdown selector with per-tool forms.""" + try: + from prefab_ui.actions import Fetch, OpenLink, SetState, ShowToast + from prefab_ui.app import PrefabApp + from prefab_ui.components import ( + Button, + Column, + Heading, + Label, + Markdown, + Muted, + Page, + Pages, + Select, + SelectOption, + Textarea, + ) + from prefab_ui.components.form import Form + from prefab_ui.rx import RESULT, Rx + except ImportError: + return "

prefab-ui not installed. Run: pip install fastmcp[apps]

" + + if not tools: + with Column(gap=4, css_class="p-6 max-w-2xl mx-auto") as view: + Heading("FastMCP Apps") + Muted( + "No UI tools found on this server. Use @app.ui() to register entry-point tools." + ) + return PrefabApp(title="FastMCP Apps", view=view).html() + + first_name: str = tools[0]["name"] + + def _tool_title(tool: dict[str, Any]) -> str: + return tool.get("title") or tool["name"] + + with Column(gap=6, css_class="p-8 max-w-2xl mx-auto") as view: + Heading("FastMCP Apps") + + if len(tools) > 1: + with Column(gap=1): + Label("Tool") + with Select( + placeholder="Choose a tool…", + on_change=SetState("activeTool", Rx("$event")), + ): + for tool in tools: + SelectOption( + _tool_title(tool), + value=tool["name"], + selected=tool["name"] == first_name, + ) + else: + Heading(_tool_title(tools[0]), level=3) + + with Pages(name="activeTool", value=first_name): + for tool in tools: + name: str = tool["name"] + desc: str = tool.get("description") or "" + input_schema: dict[str, Any] = tool.get("inputSchema") or {} + model = _model_from_schema(name, input_schema) + + form_body: dict[str, Any] = {"tool": name} + for field_name in model.model_fields: + form_body[field_name] = Rx(field_name) + + json_body: dict[str, Any] = { + "tool": name, + "__json_args__": Rx("__json_args__"), + } + + on_error = ShowToast(Rx("$error"), variant="error") # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + + input_mode = f"_mode_{name}" + _desc_max_lines = 10 + with Page(name, value=name), Column(gap=4): + if desc: + lines = desc.split("\n") + md_css = "text-sm text-muted-foreground" + if len(lines) <= _desc_max_lines: + Markdown(desc, css_class=md_css) + else: + desc_state = f"_desc_{name}" + short = "\n".join(lines[:_desc_max_lines]) + with Pages(name=desc_state, value="short"): + with ( + Page("short", value="short"), + Column(gap=1, css_class="items-start"), + ): + Markdown(short, css_class=md_css) + Button( + "Show more \u25be", + variant="link", + size="xs", + on_click=SetState(desc_state, "full"), + css_class="text-muted-foreground p-0 h-auto", + ) + with ( + Page("full", value="full"), + Column(gap=1, css_class="items-start"), + ): + Markdown(desc, css_class=md_css) + Button( + "Show less \u25b4", + variant="link", + size="xs", + on_click=SetState(desc_state, "short"), + css_class="text-muted-foreground p-0 h-auto", + ) + + with Pages(name=input_mode, value="form"): + with Page("form", value="form"), Column(gap=4): + with Column(gap=1, css_class="items-start"): + Heading("Arguments", level=3) + Button( + "Edit as JSON", + variant="link", + size="xs", + on_click=SetState(input_mode, "json"), + css_class="text-muted-foreground p-0 h-auto", + ) + with Form( + on_submit=Fetch.post( + "/api/launch", + body=form_body, + on_success=OpenLink(RESULT), + on_error=on_error, + ), + ): + Form.from_model(model, fields_only=True) + Button( + "Launch", + variant="success", + button_type="submit", + ) + with Page("json", value="json"), Column(gap=4): + with Column(gap=1, css_class="items-start"): + Heading("Arguments", level=3) + Button( + "Use form", + variant="link", + size="xs", + on_click=SetState(input_mode, "form"), + css_class="text-muted-foreground p-0 h-auto", + ) + with Form( + on_submit=Fetch.post( + "/api/launch", + body=json_body, + on_success=OpenLink(RESULT), + on_error=on_error, + ), + ): + Textarea( + name="__json_args__", + placeholder='{"key": "value"}', + rows=8, + ) + Button( + "Launch", + variant="success", + button_type="submit", + ) + + Markdown( + "Generated by [Prefab](https://prefab.prefect.io) 🎨", + css_class="text-xs text-muted-foreground text-right", + ) + + return PrefabApp(title="FastMCP Apps", view=view).html() + + +# --------------------------------------------------------------------------- +# MCP tool listing helper +# --------------------------------------------------------------------------- + + +async def _list_tools(mcp_url: str) -> list[dict[str, Any]]: + """Return raw tool dicts from the MCP server at mcp_url.""" + try: + from mcp import ClientSession + from mcp.client.streamable_http import streamable_http_client + except ImportError: + return [] + + try: + async with streamable_http_client(mcp_url) as (read, write, _): # noqa: SIM117 + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.list_tools() + return [t.model_dump() for t in result.tools] + except Exception as exc: + logger.debug(f"Could not list tools from {mcp_url}: {exc}") + return [] + + +async def _read_mcp_resource(mcp_url: str, uri: str) -> str | None: + """Read an MCP resource by URI and return its text content.""" + try: + from mcp import ClientSession + from mcp.client.streamable_http import streamable_http_client + from pydantic import AnyUrl + except ImportError: + return None + + try: + async with streamable_http_client(mcp_url) as (read, write, _): # noqa: SIM117 + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.read_resource(AnyUrl(uri)) + for content in result.contents: + text = getattr(content, "text", None) + if text: + return text + return None + except Exception as exc: + logger.debug(f"Could not read resource {uri} from {mcp_url}: {exc}") + return None + + +# --------------------------------------------------------------------------- +# app-bridge.js download, patch, and Zod import-map generation +# --------------------------------------------------------------------------- + + +def _fetch_app_bridge_bundle_sync( + version: str, + sdk_version: str, +) -> tuple[str, str]: + """Download app-bridge.js and build an import-map that fixes Zod v4 on esm.sh. + + Returns ``(app_bridge_js, import_map_json)`` where *import_map_json* is a + JSON string ready to embed in a ``' + ) + + ready = await _wait_for_server(mcp_url, timeout=15.0) + if not ready: + raise RuntimeError(f"User server did not start on port {mcp_port}") + + logger.info(f"FastMCP dev UI at {dev_url}") + + dev_app = _make_dev_app(mcp_url, app_bridge_js, import_map_tag, _MessageLog()) + config = uvicorn.Config( + dev_app, + host="localhost", + port=dev_port, + log_level="warning", + ws="websockets-sansio", + ) + server = uvicorn.Server(config) + # Suppress uvicorn's own signal handlers — they use signal.signal() which + # conflicts with asyncio and causes hangs. We cancel the task instead. + server.install_signal_handlers = lambda: None # type: ignore[method-assign] # ty:ignore[unresolved-attribute] + + async def _open_browser() -> None: + await asyncio.sleep(0.8) + webbrowser.open(dev_url) + + await asyncio.gather(server.serve(), _open_browser()) + + # Register signal handlers before any work starts so that Ctrl+C during + # startup (server spawn, npm fetch, server-ready poll) is handled the same + # way as Ctrl+C during the running phase — both cancel the body task and + # fall through to the cleanup finally block. + loop = asyncio.get_running_loop() + task = asyncio.ensure_future(_body()) + + def _on_signal() -> None: + # Silence uvicorn's error logger before cancelling so that the + # CancelledError propagating through uvicorn doesn't get logged as + # an ERROR during the forced shutdown. + logging.getLogger("uvicorn.error").setLevel(logging.CRITICAL) + task.cancel() + + if sys.platform != "win32": + loop.add_signal_handler(signal.SIGINT, _on_signal) + loop.add_signal_handler(signal.SIGTERM, _on_signal) + + try: + await task + except asyncio.CancelledError: + pass + finally: + if sys.platform != "win32": + loop.remove_signal_handler(signal.SIGINT) + loop.remove_signal_handler(signal.SIGTERM) + if user_proc is not None and user_proc.returncode is None: + # Kill the entire process group (not just the top-level process) + # because --reload creates a watcher that spawns child processes. + # Killing only the watcher leaves the actual server holding the port. + try: + if sys.platform != "win32": + os.killpg(os.getpgid(user_proc.pid), signal.SIGTERM) + else: + user_proc.kill() + except (ProcessLookupError, PermissionError): + user_proc.kill() + await user_proc.wait() diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index f6472c470..876da5826 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -39,7 +39,7 @@ console = Console() app = cyclopts.App( name="fastmcp", - help="FastMCP 2.0 - The fast, Pythonic way to build MCP servers and clients.", + help="FastMCP - The fast, Pythonic way to build MCP servers and clients.", version=fastmcp.__version__, # Disable automatic negative parameters by default default_parameter=Parameter(negative=()), @@ -54,7 +54,7 @@ def _get_npx_command(): try: subprocess.run([cmd, "--version"], check=True, capture_output=True) return cmd - except subprocess.CalledProcessError: + except (subprocess.CalledProcessError, FileNotFoundError): continue return None return "npx" # On Unix-like systems, just use npx @@ -333,6 +333,54 @@ async def inspector( sys.exit(1) +@dev_app.command +async def apps( + server_spec: str, + *, + mcp_port: Annotated[ + int, + cyclopts.Parameter( + "--mcp-port", + help="Port for the user's MCP server", + ), + ] = 8000, + dev_port: Annotated[ + int, + cyclopts.Parameter( + "--dev-port", + help="Port for the FastMCP dev UI", + ), + ] = 8080, + reload: Annotated[ + bool, + cyclopts.Parameter( + "--reload", + negative="--no-reload", + help="Auto-reload the MCP server on file changes", + ), + ] = True, +) -> None: + """Preview a FastMCPApp UI in the browser. + + Starts the MCP server from SERVER_SPEC on --mcp-port, launches a local + dev UI on --dev-port with a tool picker and AppBridge host, then opens + the browser automatically. + + Requires fastmcp[apps] to be installed (prefab-ui). + """ + try: + import prefab_ui # noqa: F401 + except ImportError: + logger.error( + "fastmcp dev apps requires prefab-ui. Install with: pip install 'fastmcp[apps]'" + ) + sys.exit(1) + + from fastmcp.cli.apps_dev import run_dev_apps + + await run_dev_apps(server_spec, mcp_port=mcp_port, dev_port=dev_port, reload=reload) + + @app.command async def run( server_spec: str | None = None, diff --git a/src/fastmcp/cli/client.py b/src/fastmcp/cli/client.py index 9a14e4f06..cc43b3aff 100644 --- a/src/fastmcp/cli/client.py +++ b/src/fastmcp/cli/client.py @@ -10,6 +10,7 @@ from typing import Annotated, Any, Literal import cyclopts import mcp.types from rich.console import Console +from rich.markup import escape as escape_rich_markup from fastmcp.cli.discovery import DiscoveredServer, discover_servers, resolve_name from fastmcp.client.client import CallToolResult, Client @@ -405,15 +406,30 @@ def _print_schema(label: str, schema: dict[str, Any]) -> None: console.print(f" [dim]{label}: {json.dumps(schema)}[/dim]") +def _sanitize_untrusted_text(value: str) -> str: + """Escape rich markup and encode control chars for terminal-safe output.""" + sanitized = escape_rich_markup(value) + return "".join( + ch + if ch in {"\n", "\t"} or (0x20 <= ord(ch) < 0x7F) or ord(ch) > 0x9F + else f"\\x{ord(ch):02x}" + for ch in sanitized + ) + + def _format_call_result_text(result: CallToolResult) -> None: """Pretty-print a tool call result to the console.""" if result.is_error: for block in result.content: if isinstance(block, mcp.types.TextContent): - console.print(f"[bold red]Error:[/bold red] {block.text}") + console.print( + f"[bold red]Error:[/bold red] {_sanitize_untrusted_text(block.text)}" + ) else: - console.print(f"[bold red]Error:[/bold red] {block}") + console.print( + f"[bold red]Error:[/bold red] {_sanitize_untrusted_text(str(block))}" + ) return if result.structured_content is not None: @@ -422,7 +438,7 @@ def _format_call_result_text(result: CallToolResult) -> None: for block in result.content: if isinstance(block, mcp.types.TextContent): - console.print(block.text) + console.print(_sanitize_untrusted_text(block.text)) elif isinstance(block, mcp.types.ImageContent): size = len(block.data) * 3 // 4 # rough decoded size console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]") @@ -430,7 +446,7 @@ def _format_call_result_text(result: CallToolResult) -> None: size = len(block.data) * 3 // 4 console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]") else: - console.print(str(block)) + console.print(_sanitize_untrusted_text(str(block))) def _content_block_to_dict(block: mcp.types.ContentBlock) -> dict[str, Any]: @@ -554,7 +570,7 @@ async def _handle_resource( for block in contents: if isinstance(block, mcp.types.TextResourceContents): - console.print(block.text) + console.print(_sanitize_untrusted_text(block.text)) elif isinstance(block, mcp.types.BlobResourceContents): size = len(block.blob) * 3 // 4 console.print(f"[dim][Blob: {block.mimeType}, ~{size} bytes][/dim]") @@ -604,16 +620,16 @@ async def _handle_prompt( return for msg in result.messages: - console.print(f"[bold]{msg.role}:[/bold]") + console.print(f"[bold]{_sanitize_untrusted_text(msg.role)}:[/bold]") if isinstance(msg.content, mcp.types.TextContent): - console.print(f" {msg.content.text}") + console.print(f" {_sanitize_untrusted_text(msg.content.text)}") elif isinstance(msg.content, mcp.types.ImageContent): size = len(msg.content.data) * 3 // 4 console.print( f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]" ) else: - console.print(f" {msg.content}") + console.print(f" {_sanitize_untrusted_text(str(msg.content))}") console.print() @@ -727,9 +743,11 @@ async def list_command( console.print() for tool in tools: sig = format_tool_signature(tool) - console.print(f" [cyan]{sig}[/cyan]") + console.print(f" [cyan]{_sanitize_untrusted_text(sig)}[/cyan]") if tool.description: - console.print(f" {tool.description}") + console.print( + f" {_sanitize_untrusted_text(tool.description)}" + ) if input_schema: _print_schema("Input", tool.inputSchema) if output_schema and tool.outputSchema: @@ -743,11 +761,13 @@ async def list_command( if not res: console.print(" [dim]No resources found.[/dim]") for r in res: - console.print(f" [cyan]{r.uri}[/cyan]") + console.print( + f" [cyan]{_sanitize_untrusted_text(str(r.uri))}[/cyan]" + ) desc_parts = [r.name or "", r.description or ""] desc = " — ".join(p for p in desc_parts if p) if desc: - console.print(f" {desc}") + console.print(f" {_sanitize_untrusted_text(desc)}") console.print() if prompts: @@ -761,9 +781,11 @@ async def list_command( if p.arguments: parts = [a.name for a in p.arguments] args_str = f"({', '.join(parts)})" - console.print(f" [cyan]{p.name}{args_str}[/cyan]") + console.print( + f" [cyan]{_sanitize_untrusted_text(p.name + args_str)}[/cyan]" + ) if p.description: - console.print(f" {p.description}") + console.print(f" {_sanitize_untrusted_text(p.description)}") console.print() except Exception as exc: diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index a88413a32..5aa377568 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -12,7 +12,7 @@ from rich import print from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment -from .shared import process_common_args +from .shared import process_common_args, validate_server_name logger = get_logger(__name__) @@ -124,6 +124,8 @@ def install_claude_code( # Build the full command full_command = env_config.build_command(["fastmcp", "run", server_spec]) + validate_server_name(name) + # Build claude mcp add command cmd_parts = [claude_cmd, "mcp", "add", name] diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py index 83755c162..780b97c36 100644 --- a/src/fastmcp/cli/install/claude_desktop.py +++ b/src/fastmcp/cli/install/claude_desktop.py @@ -17,8 +17,19 @@ from .shared import process_common_args logger = get_logger(__name__) -def get_claude_config_path() -> Path | None: - """Get the Claude config directory based on platform.""" +def get_claude_config_path(config_path: Path | None = None) -> Path | None: + """Get the Claude config directory based on platform. + + Args: + config_path: Optional custom path to the Claude Desktop config directory + """ + + if config_path: + if not config_path.exists(): + print(f"[red]The specified config path does not exist: {config_path}[/red]") + return None + return config_path + if sys.platform == "win32": path = Path(Path.home(), "AppData", "Roaming", "Claude") elif sys.platform == "darwin": @@ -46,6 +57,7 @@ def install_claude_desktop( python_version: str | None = None, with_requirements: Path | None = None, project: Path | None = None, + config_path: Path | None = None, ) -> bool: """Install FastMCP server in Claude Desktop. @@ -59,16 +71,18 @@ def install_claude_desktop( python_version: Optional Python version to use with_requirements: Optional requirements file to install from project: Optional project directory to run within + config_path: Optional custom path to Claude Desktop config directory Returns: True if installation was successful, False otherwise """ - config_dir = get_claude_config_path() + config_dir = get_claude_config_path(config_path=config_path) if not config_dir: - print( - "[red]Claude Desktop config directory not found.[/red]\n" - "[blue]Please ensure Claude Desktop is installed and has been run at least once to initialize its config.[/blue]" - ) + if not config_path: + print( + "[red]Claude Desktop config directory not found.[/red]\n" + "[blue]Please ensure Claude Desktop is installed and has been run at least once to initialize its config.[/blue]" + ) return False config_file = config_dir / "claude_desktop_config.json" @@ -180,6 +194,13 @@ async def claude_desktop_command( help="Run the command within the given project directory", ), ] = None, + config_path: Annotated[ + Path | None, + cyclopts.Parameter( + "--config-path", + help="Custom path to Claude Desktop config directory", + ), + ] = None, ) -> None: """Install an MCP server in Claude Desktop. @@ -204,6 +225,7 @@ async def claude_desktop_command( python_version=python, with_requirements=with_requirements, project=project, + config_path=config_path, ) if not success: diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index 0358c3261..560d17e5d 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -91,6 +91,9 @@ def install_cursor_workspace( if not workspace_path.exists(): print(f"[red]Workspace directory does not exist: {workspace_path}[/red]") return False + if not workspace_path.is_dir(): + print(f"[red]Workspace path is not a directory: {workspace_path}[/red]") + return False # Create .cursor directory in workspace cursor_dir = workspace_path / ".cursor" diff --git a/src/fastmcp/cli/install/gemini_cli.py b/src/fastmcp/cli/install/gemini_cli.py index 42b6ec778..29cc39e15 100644 --- a/src/fastmcp/cli/install/gemini_cli.py +++ b/src/fastmcp/cli/install/gemini_cli.py @@ -12,7 +12,7 @@ from rich import print from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment -from .shared import process_common_args +from .shared import process_common_args, validate_server_name logger = get_logger(__name__) @@ -129,6 +129,8 @@ def install_gemini_cli( for key, value in env_vars.items(): cmd_parts.extend(["-e", f"{key}={value}"]) + validate_server_name(name) + # Add server name and command cmd_parts.extend([name, full_command[0], "--"]) cmd_parts.extend(full_command[1:]) diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py index 130237344..ed2ac7382 100644 --- a/src/fastmcp/cli/install/mcp_json.py +++ b/src/fastmcp/cli/install/mcp_json.py @@ -65,7 +65,7 @@ def install_mcp_json( full_command = env_config.build_command(["fastmcp", "run", server_spec]) # Build MCP server configuration - server_config = { + server_config: dict[str, str | list[str] | dict[str, str]] = { "command": full_command[0], "args": full_command[1:], } diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index fe980dce6..df22f5bbf 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -2,6 +2,7 @@ import json import os +import re import subprocess import sys from pathlib import Path @@ -17,6 +18,26 @@ from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystem logger = get_logger(__name__) +# Server names are passed as subprocess arguments to CLI tools like `claude` +# and `gemini`. On Windows these may resolve to .cmd/.bat wrappers that run +# through cmd.exe, where shell metacharacters (& | ; etc.) in arguments can +# cause command injection. Restrict names to safe characters. +_SAFE_NAME_RE = re.compile(r"^[\w\-. ]+$") + + +def validate_server_name(name: str) -> str: + """Validate that a server name is safe for use as a subprocess argument. + + Raises SystemExit if the name contains shell metacharacters. + """ + if not _SAFE_NAME_RE.match(name): + print( + f"[red]Invalid server name '[bold]{name}[/bold]': " + "names may only contain letters, numbers, hyphens, underscores, dots, and spaces.[/red]" + ) + sys.exit(1) + return name + def parse_env_var(env_var: str) -> tuple[str, str]: """Parse environment variable string in format KEY=VALUE.""" diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index a4d1e9c77..64416393a 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -96,9 +96,16 @@ class TokenStorageAdapter(TokenStorage): def _get_client_info_cache_key(self) -> str: return f"{self._server_url}/client_info" + def _get_token_expiry_cache_key(self) -> str: + return f"{self._server_url}/token_expiry" + async def clear(self) -> None: await self._storage_oauth_token.delete(key=self._get_token_cache_key()) await self._storage_client_info.delete(key=self._get_client_info_cache_key()) + await self._key_value_store.delete( + key=self._get_token_expiry_cache_key(), + collection="mcp-oauth-token-expiry", + ) @override async def get_tokens(self) -> OAuthToken | None: @@ -114,6 +121,25 @@ class TokenStorageAdapter(TokenStorage): value=tokens, ttl=60 * 60 * 24 * 365, # 1 year ) + # Store absolute expiry so reloads don't misinterpret the stale + # relative expires_in value (#2862). + if tokens.expires_in is not None: + expires_at = time.time() + int(tokens.expires_in) + await self._key_value_store.put( + key=self._get_token_expiry_cache_key(), + value={"expires_at": expires_at}, + collection="mcp-oauth-token-expiry", + ttl=60 * 60 * 24 * 365, + ) + + async def get_token_expiry(self) -> float | None: + raw = await self._key_value_store.get( + key=self._get_token_expiry_cache_key(), + collection="mcp-oauth-token-expiry", + ) + if raw is not None: + return float(raw["expires_at"]) + return None @override async def get_client_info(self) -> OAuthClientInformationFull | None: @@ -285,7 +311,11 @@ class OAuth(OAuthClientProvider): await self.token_storage_adapter.set_client_info(self._static_client_info) if self.context.current_tokens and self.context.current_tokens.expires_in: - self.context.update_token_expiry(self.context.current_tokens) + stored_expiry = await self.token_storage_adapter.get_token_expiry() + if stored_expiry is not None: + self.context.token_expiry_time = stored_expiry + else: + self.context.update_token_expiry(self.context.current_tokens) async def redirect_handler(self, authorization_url: str) -> None: """Open browser for authorization, with pre-flight check for invalid client.""" diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index efacf671e..17fb7be90 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -4,6 +4,7 @@ import asyncio import copy import datetime import secrets +import ssl import weakref from collections.abc import Coroutine from contextlib import AsyncExitStack, asynccontextmanager, suppress @@ -20,6 +21,7 @@ from mcp.types import GetTaskResult, TaskStatusNotification from pydantic import AnyUrl import fastmcp +from fastmcp.client.auth.oauth import OAuth from fastmcp.client.elicitation import ElicitationHandler, create_elicitation_callback from fastmcp.client.logging import ( LogHandler, @@ -258,10 +260,34 @@ class Client( init_timeout: datetime.timedelta | float | int | None = None, client_info: mcp.types.Implementation | None = None, auth: httpx.Auth | Literal["oauth"] | str | None = None, + verify: ssl.SSLContext | bool | str | None = None, ) -> None: self.name = name or self.generate_name() self.transport = cast(ClientTransportT, infer_transport(transport)) + + if verify is not None: + from fastmcp.client.transports.http import StreamableHttpTransport + from fastmcp.client.transports.sse import SSETransport + + if isinstance(self.transport, StreamableHttpTransport | SSETransport): + self.transport.verify = verify + # Re-sync existing OAuth auth with the new verify setting, + # but only if the transport doesn't have a custom factory + # (which takes precedence and was already applied to OAuth). + if ( + isinstance(self.transport.auth, OAuth) + and auth is None + and self.transport.httpx_client_factory is None + ): + verify_factory = self.transport._make_verify_factory() + if verify_factory is not None: + self.transport.auth.httpx_client_factory = verify_factory + else: + raise ValueError( + "The 'verify' parameter is only supported for HTTP transports." + ) + if auth is not None: self.transport._set_auth(auth) @@ -310,6 +336,11 @@ class Client( elicitation_handler ) + # Maximum time to wait for a clean disconnect before giving up. + # Normally disconnects complete in <100ms; this is a safety net for + # unresponsive servers. + self._disconnect_timeout: float = fastmcp.settings.client_disconnect_timeout + # Session context management - see class docstring for detailed explanation self._session_state = ClientSessionState() @@ -485,7 +516,7 @@ class Client( # Use a timeout to prevent hanging during cleanup if the connection is in a bad # state (e.g., rate-limited). The MCP SDK's transport may try to terminate the # session which can hang if the server is unresponsive. - with anyio.move_on_after(5): + with anyio.move_on_after(self._disconnect_timeout): await self._disconnect() async def _connect(self): diff --git a/src/fastmcp/client/elicitation.py b/src/fastmcp/client/elicitation.py index 8df76c5b0..60545a744 100644 --- a/src/fastmcp/client/elicitation.py +++ b/src/fastmcp/client/elicitation.py @@ -66,7 +66,7 @@ def create_elicitation_callback( f"{result.content!r}" ) return MCPElicitResult( - _meta=result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + _meta=result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] action=result.action, content=content, ) diff --git a/src/fastmcp/client/messages.py b/src/fastmcp/client/messages.py index c50316cec..361dfe0bf 100644 --- a/src/fastmcp/client/messages.py +++ b/src/fastmcp/client/messages.py @@ -36,17 +36,17 @@ class MessageHandler: case RequestResponder(): # handle all requests # TODO(ty): remove when ty supports match statement narrowing - await self.on_request(message) # type: ignore[arg-type] + await self.on_request(message) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] # handle specific requests # TODO(ty): remove type ignores when ty supports match statement narrowing - match message.request.root: # type: ignore[union-attr] + match message.request.root: # type: ignore[union-attr] # ty:ignore[unresolved-attribute] case mcp.types.PingRequest(): - await self.on_ping(message.request.root) # type: ignore[union-attr] + await self.on_ping(message.request.root) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] case mcp.types.ListRootsRequest(): - await self.on_list_roots(message.request.root) # type: ignore[union-attr] + await self.on_list_roots(message.request.root) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] case mcp.types.CreateMessageRequest(): - await self.on_create_message(message.request.root) # type: ignore[union-attr] + await self.on_create_message(message.request.root) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] # notifications case mcp.types.ServerNotification(): diff --git a/src/fastmcp/client/mixins/prompts.py b/src/fastmcp/client/mixins/prompts.py index e8907c194..4b87bf270 100644 --- a/src/fastmcp/client/mixins/prompts.py +++ b/src/fastmcp/client/mixins/prompts.py @@ -20,6 +20,8 @@ from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) +AUTO_PAGINATION_MAX_PAGES = 250 + # Type alias for task response union (SEP-1686 graceful degradation) PromptTaskResponseUnion = RootModel[ mcp.types.CreateTaskResult | mcp.types.GetPromptResult @@ -54,25 +56,31 @@ class ClientPromptsMixin: ) return result - async def list_prompts(self: Client) -> list[mcp.types.Prompt]: + async def list_prompts( + self: Client, + max_pages: int = AUTO_PAGINATION_MAX_PAGES, + ) -> list[mcp.types.Prompt]: """Retrieve all prompts available on the server. This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_prompts_mcp() with the cursor parameter. + Args: + max_pages: Maximum number of pages to fetch before raising. Defaults to 250. + Returns: list[mcp.types.Prompt]: A list of all Prompt objects. Raises: - RuntimeError: If called while the client is not connected. + RuntimeError: If the page limit is reached before pagination completes. McpError: If the request results in a TimeoutError | JSONRPCError """ all_prompts: list[mcp.types.Prompt] = [] cursor: str | None = None seen_cursors: set[str] = set() - while True: + for _ in range(max_pages): result = await self.list_prompts_mcp(cursor=cursor) all_prompts.extend(result.prompts) if not result.nextCursor: @@ -85,6 +93,13 @@ class ClientPromptsMixin: break seen_cursors.add(result.nextCursor) cursor = result.nextCursor + else: + raise RuntimeError( + f"[{self.name}] Reached auto-pagination limit" + f" ({max_pages} pages) for list_prompts." + " Use list_prompts_mcp() with cursor for manual pagination," + " or increase max_pages." + ) return all_prompts @@ -142,12 +157,12 @@ class ClientPromptsMixin: name=name, arguments=serialized_arguments, task=mcp.types.TaskMetadata(**task_dict) if task_dict else None, - _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias + _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument] ) ) result = await self._await_with_session_monitoring( self.session.send_request( - request=request, # type: ignore[arg-type] + request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] result_type=mcp.types.GetPromptResult, ) ) @@ -272,14 +287,14 @@ class ClientPromptsMixin: name=name, arguments=serialized_arguments, task=mcp.types.TaskMetadata(ttl=ttl), - _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias + _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument] ) ) # Server returns CreateTaskResult (task accepted) or GetPromptResult (graceful degradation) wrapped_result = await self._await_with_session_monitoring( self.session.send_request( - request=request, # type: ignore[arg-type] + request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] result_type=PromptTaskResponseUnion, ) ) diff --git a/src/fastmcp/client/mixins/resources.py b/src/fastmcp/client/mixins/resources.py index f49f861f8..c0dc27fff 100644 --- a/src/fastmcp/client/mixins/resources.py +++ b/src/fastmcp/client/mixins/resources.py @@ -19,6 +19,8 @@ from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) +AUTO_PAGINATION_MAX_PAGES = 250 + # Type alias for task response union (SEP-1686 graceful degradation) ResourceTaskResponseUnion = RootModel[ mcp.types.CreateTaskResult | mcp.types.ReadResourceResult @@ -53,25 +55,31 @@ class ClientResourcesMixin: ) return result - async def list_resources(self: Client) -> list[mcp.types.Resource]: + async def list_resources( + self: Client, + max_pages: int = AUTO_PAGINATION_MAX_PAGES, + ) -> list[mcp.types.Resource]: """Retrieve all resources available on the server. This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_resources_mcp() with the cursor parameter. + Args: + max_pages: Maximum number of pages to fetch before raising. Defaults to 250. + Returns: list[mcp.types.Resource]: A list of all Resource objects. Raises: - RuntimeError: If called while the client is not connected. + RuntimeError: If the page limit is reached before pagination completes. McpError: If the request results in a TimeoutError | JSONRPCError """ all_resources: list[mcp.types.Resource] = [] cursor: str | None = None seen_cursors: set[str] = set() - while True: + for _ in range(max_pages): result = await self.list_resources_mcp(cursor=cursor) all_resources.extend(result.resources) if not result.nextCursor: @@ -84,6 +92,13 @@ class ClientResourcesMixin: break seen_cursors.add(result.nextCursor) cursor = result.nextCursor + else: + raise RuntimeError( + f"[{self.name}] Reached auto-pagination limit" + f" ({max_pages} pages) for list_resources." + " Use list_resources_mcp() with cursor for manual pagination," + " or increase max_pages." + ) return all_resources @@ -110,7 +125,10 @@ class ClientResourcesMixin: ) return result - async def list_resource_templates(self: Client) -> list[mcp.types.ResourceTemplate]: + async def list_resource_templates( + self: Client, + max_pages: int = AUTO_PAGINATION_MAX_PAGES, + ) -> list[mcp.types.ResourceTemplate]: """Retrieve all resource templates available on the server. This method automatically fetches all pages if the server paginates results, @@ -118,18 +136,21 @@ class ClientResourcesMixin: large result sets incrementally), use list_resource_templates_mcp() with the cursor parameter. + Args: + max_pages: Maximum number of pages to fetch before raising. Defaults to 250. + Returns: list[mcp.types.ResourceTemplate]: A list of all ResourceTemplate objects. Raises: - RuntimeError: If called while the client is not connected. + RuntimeError: If the page limit is reached before pagination completes. McpError: If the request results in a TimeoutError | JSONRPCError """ all_templates: list[mcp.types.ResourceTemplate] = [] cursor: str | None = None seen_cursors: set[str] = set() - while True: + for _ in range(max_pages): result = await self.list_resource_templates_mcp(cursor=cursor) all_templates.extend(result.resourceTemplates) if not result.nextCursor: @@ -143,6 +164,13 @@ class ClientResourcesMixin: break seen_cursors.add(result.nextCursor) cursor = result.nextCursor + else: + raise RuntimeError( + f"[{self.name}] Reached auto-pagination limit" + f" ({max_pages} pages) for list_resource_templates." + " Use list_resource_templates_mcp() with cursor for manual pagination," + " or increase max_pages." + ) return all_templates @@ -186,12 +214,12 @@ class ClientResourcesMixin: params=mcp.types.ReadResourceRequestParams( uri=uri, task=mcp.types.TaskMetadata(**task_dict) if task_dict else None, - _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias + _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument] ) ) result = await self._await_with_session_monitoring( self.session.send_request( - request=request, # type: ignore[arg-type] + request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] result_type=mcp.types.ReadResourceResult, ) ) @@ -308,14 +336,14 @@ class ClientResourcesMixin: params=mcp.types.ReadResourceRequestParams( uri=uri, task=mcp.types.TaskMetadata(ttl=ttl), - _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias + _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument] ) ) # Server returns CreateTaskResult (task accepted) or ReadResourceResult (graceful degradation) wrapped_result = await self._await_with_session_monitoring( self.session.send_request( - request=request, # type: ignore[arg-type] + request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] result_type=ResourceTaskResponseUnion, ) ) diff --git a/src/fastmcp/client/mixins/task_management.py b/src/fastmcp/client/mixins/task_management.py index 4c192e55e..40594bb4a 100644 --- a/src/fastmcp/client/mixins/task_management.py +++ b/src/fastmcp/client/mixins/task_management.py @@ -48,7 +48,7 @@ class ClientTaskManagementMixin: request = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id)) return await self._await_with_session_monitoring( self.session.send_request( - request=request, # type: ignore[arg-type] + request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] result_type=GetTaskResult, ) ) @@ -75,7 +75,7 @@ class ClientTaskManagementMixin: # Return raw result - Task classes handle type-specific parsing result = await self._await_with_session_monitoring( self.session.send_request( - request=request, # type: ignore[arg-type] + request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] result_type=GetTaskPayloadResult, ) ) @@ -107,11 +107,11 @@ class ClientTaskManagementMixin: McpError: If the request results in a TimeoutError | JSONRPCError """ # Send protocol request - params = PaginatedRequestParams(cursor=cursor, limit=limit) # type: ignore[call-arg] # Optional field in MCP SDK + params = PaginatedRequestParams(cursor=cursor, limit=limit) # type: ignore[call-arg] # Optional field in MCP SDK # ty:ignore[unknown-argument] request = ListTasksRequest(params=params) server_response = await self._await_with_session_monitoring( self.session.send_request( - request=request, # type: ignore[invalid-argument-type] + request=request, # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type] result_type=mcp.types.ListTasksResult, ) ) @@ -151,7 +151,7 @@ class ClientTaskManagementMixin: request = CancelTaskRequest(params=CancelTaskRequestParams(taskId=task_id)) return await self._await_with_session_monitoring( self.session.send_request( - request=request, # type: ignore[invalid-argument-type] + request=request, # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type] result_type=mcp.types.CancelTaskResult, ) ) diff --git a/src/fastmcp/client/mixins/tools.py b/src/fastmcp/client/mixins/tools.py index f702e4938..d6c37a5bd 100644 --- a/src/fastmcp/client/mixins/tools.py +++ b/src/fastmcp/client/mixins/tools.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid import weakref -from typing import TYPE_CHECKING, Any, Literal, overload +from typing import TYPE_CHECKING, Any, Literal, cast, overload import mcp.types from pydantic import RootModel @@ -25,6 +25,8 @@ from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger(__name__) +AUTO_PAGINATION_MAX_PAGES = 250 + # Type alias for task response union (SEP-1686 graceful degradation) ToolTaskResponseUnion = RootModel[mcp.types.CreateTaskResult | mcp.types.CallToolResult] @@ -57,25 +59,31 @@ class ClientToolsMixin: ) return result - async def list_tools(self: Client) -> list[mcp.types.Tool]: + async def list_tools( + self: Client, + max_pages: int = AUTO_PAGINATION_MAX_PAGES, + ) -> list[mcp.types.Tool]: """Retrieve all tools available on the server. This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_tools_mcp() with the cursor parameter. + Args: + max_pages: Maximum number of pages to fetch before raising. Defaults to 250. + Returns: list[mcp.types.Tool]: A list of all Tool objects. Raises: - RuntimeError: If called while the client is not connected. + RuntimeError: If the page limit is reached before pagination completes. McpError: If the request results in a TimeoutError | JSONRPCError """ all_tools: list[mcp.types.Tool] = [] cursor: str | None = None seen_cursors: set[str] = set() - while True: + for _ in range(max_pages): result = await self.list_tools_mcp(cursor=cursor) all_tools.extend(result.tools) if not result.nextCursor: @@ -88,6 +96,13 @@ class ClientToolsMixin: break seen_cursors.add(result.nextCursor) cursor = result.nextCursor + else: + raise RuntimeError( + f"[{self.name}] Reached auto-pagination limit" + f" ({max_pages} pages) for list_tools." + " Use list_tools_mcp() with cursor for manual pagination," + " or increase max_pages." + ) return all_tools @@ -307,7 +322,7 @@ class ClientToolsMixin: name=name, arguments=arguments or {}, task=mcp.types.TaskMetadata(ttl=ttl), - _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias + _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument] ) ) @@ -315,7 +330,7 @@ class ClientToolsMixin: # Use RootModel with Union to handle both response types (SDK calls model_validate) wrapped_result = await self._await_with_session_monitoring( self.session.send_request( - request=request, # type: ignore[arg-type] + request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] result_type=ToolTaskResponseUnion, ) ) @@ -364,8 +379,9 @@ async def _parse_call_tool_result( Returns: CallToolResult: Parsed result with structured data """ - from typing import cast - + # Local import: CallToolResult is under TYPE_CHECKING at module level to + # avoid a circular import (client.client -> mixins.tools -> client.client), + # but we need the concrete class here to construct the return value. from fastmcp.client.client import CallToolResult data = None @@ -374,23 +390,43 @@ async def _parse_call_tool_result( raise ToolError(msg) elif result.structuredContent: try: + raw_fastmcp_meta = (result.meta or {}).get("fastmcp") + fastmcp_meta = ( + raw_fastmcp_meta if isinstance(raw_fastmcp_meta, dict) else {} + ) + wrap_from_meta = fastmcp_meta.get("wrap_result", False) + + # Ensure the schema cache is populated for type validation. + # When meta tells us the result is wrapped we can skip the + # schema check for *wrap detection*, but we still need the + # schema for proper type coercion (e.g. list → set, str → datetime). if name not in tool_output_schemas: await list_tools_fn() - if name in tool_output_schemas: + + if wrap_from_meta: + # Meta tells us the result is wrapped — unwrap and validate. + structured_content = result.structuredContent.get("result") + elif name in tool_output_schemas: output_schema = tool_output_schemas.get(name) - if output_schema: - if output_schema.get("x-fastmcp-wrap-result"): - output_schema = output_schema.get("properties", {}).get( - "result" - ) - structured_content = result.structuredContent.get("result") - else: - structured_content = result.structuredContent - output_type = json_schema_to_type(output_schema) - type_adapter = get_cached_typeadapter(output_type) - data = type_adapter.validate_python(structured_content) + if output_schema and output_schema.get("x-fastmcp-wrap-result"): + structured_content = result.structuredContent.get("result") else: - data = result.structuredContent + structured_content = result.structuredContent + else: + structured_content = result.structuredContent + + # Type-validate through the schema if available. + output_schema = tool_output_schemas.get(name) + if output_schema: + if wrap_from_meta or output_schema.get("x-fastmcp-wrap-result"): + output_schema = output_schema.get("properties", {}).get( + "result", output_schema + ) + output_type = json_schema_to_type(output_schema) + type_adapter = get_cached_typeadapter(output_type) + data = type_adapter.validate_python(structured_content) + else: + data = structured_content except Exception as e: logger.error( f"[{client_name or 'client'}] Error parsing structured content: {e}" diff --git a/src/fastmcp/client/oauth_callback.py b/src/fastmcp/client/oauth_callback.py index ba5c93d54..6bfba59cb 100644 --- a/src/fastmcp/client/oauth_callback.py +++ b/src/fastmcp/client/oauth_callback.py @@ -121,6 +121,21 @@ def create_oauth_callback_server( Configured uvicorn Server instance (not yet running) """ + def store_result_once( + *, + code: str | None = None, + state: str | None = None, + error: Exception | None = None, + ) -> None: + """Store the first callback result and ignore subsequent requests.""" + if result_container is None or result_ready is None or result_ready.is_set(): + return + + result_container.code = code + result_container.state = state + result_container.error = error + result_ready.set() + async def callback_handler(request: Request): """Handle OAuth callback requests with proper HTML responses.""" query_params = dict(request.query_params) @@ -136,9 +151,7 @@ def create_oauth_callback_server( user_message = f"Authorization failed: {error_desc}" # Store error and signal completion if result tracking provided - if result_container is not None and result_ready is not None: - result_container.error = RuntimeError(user_message) - result_ready.set() + store_result_once(error=RuntimeError(user_message)) return create_secure_html_response( create_callback_html( @@ -152,9 +165,7 @@ def create_oauth_callback_server( user_message = "No authorization code was received from the server." # Store error and signal completion if result tracking provided - if result_container is not None and result_ready is not None: - result_container.error = RuntimeError(user_message) - result_ready.set() + store_result_once(error=RuntimeError(user_message)) return create_secure_html_response( create_callback_html( @@ -171,9 +182,7 @@ def create_oauth_callback_server( ) # Store error and signal completion if result tracking provided - if result_container is not None and result_ready is not None: - result_container.error = RuntimeError(user_message) - result_ready.set() + store_result_once(error=RuntimeError(user_message)) return create_secure_html_response( create_callback_html( @@ -184,10 +193,10 @@ def create_oauth_callback_server( ) # Success case - store result and signal completion if result tracking provided - if result_container is not None and result_ready is not None: - result_container.code = callback_response.code - result_container.state = callback_response.state - result_ready.set() + store_result_once( + code=callback_response.code, + state=callback_response.state, + ) return create_secure_html_response( create_callback_html("", is_success=True, server_url=server_url) diff --git a/src/fastmcp/client/progress.py b/src/fastmcp/client/progress.py index 2470f18ce..826d2cb99 100644 --- a/src/fastmcp/client/progress.py +++ b/src/fastmcp/client/progress.py @@ -21,10 +21,13 @@ async def default_progress_handler( total: Optional total expected value message: Optional status message """ - if total is not None: + if total not in (None, 0): # We have both progress and total percent = (progress / total) * 100 progress_str = f"{progress}/{total} ({percent:.1f}%)" + elif total == 0: + # Avoid division by zero when a server reports an invalid total. + progress_str = f"{progress}/{total}" else: # We only have progress progress_str = f"{progress}" diff --git a/src/fastmcp/client/roots.py b/src/fastmcp/client/roots.py index 1a55ac36a..cdf97938b 100644 --- a/src/fastmcp/client/roots.py +++ b/src/fastmcp/client/roots.py @@ -35,7 +35,7 @@ def create_roots_callback( ) -> ListRootsFnT: if isinstance(handler, list): # TODO(ty): remove when ty supports isinstance union narrowing - return _create_roots_callback_from_roots(handler) # type: ignore[arg-type] + return _create_roots_callback_from_roots(handler) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] elif inspect.isfunction(handler): return _create_roots_callback_from_fn(handler) else: diff --git a/src/fastmcp/client/sampling/handlers/anthropic.py b/src/fastmcp/client/sampling/handlers/anthropic.py index 4bef921b3..b7a6ce090 100644 --- a/src/fastmcp/client/sampling/handlers/anthropic.py +++ b/src/fastmcp/client/sampling/handlers/anthropic.py @@ -3,10 +3,11 @@ from collections.abc import Iterator, Sequence from typing import Any -from mcp.types import CreateMessageRequestParams as SamplingParams from mcp.types import ( + AudioContent, CreateMessageResult, CreateMessageResultWithTools, + ImageContent, ModelPreferences, SamplingMessage, SamplingMessageContentBlock, @@ -17,10 +18,13 @@ from mcp.types import ( ToolResultContent, ToolUseContent, ) +from mcp.types import CreateMessageRequestParams as SamplingParams try: from anthropic import AsyncAnthropic from anthropic.types import ( + Base64ImageSourceParam, + ImageBlockParam, Message, MessageParam, TextBlock, @@ -42,6 +46,28 @@ except ImportError as e: __all__ = ["AnthropicSamplingHandler"] +# Anthropic supports these image MIME types +_ANTHROPIC_IMAGE_MEDIA_TYPES = frozenset( + {"image/jpeg", "image/png", "image/gif", "image/webp"} +) + + +def _image_content_to_anthropic_block(content: ImageContent) -> ImageBlockParam: + """Convert MCP ImageContent to Anthropic ImageBlockParam.""" + if content.mimeType not in _ANTHROPIC_IMAGE_MEDIA_TYPES: + raise ValueError( + f"Unsupported image MIME type for Anthropic: {content.mimeType!r}. " + f"Supported types: {', '.join(sorted(_ANTHROPIC_IMAGE_MEDIA_TYPES))}" + ) + return ImageBlockParam( + type="image", + source=Base64ImageSourceParam( + type="base64", + media_type=content.mimeType, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + data=content.data, + ), + ) + class AnthropicSamplingHandler: """Sampling handler that uses the Anthropic API. @@ -155,7 +181,10 @@ class AnthropicSamplingHandler: # Handle list content (from CreateMessageResultWithTools) if isinstance(content, list): content_blocks: list[ - TextBlockParam | ToolUseBlockParam | ToolResultBlockParam + TextBlockParam + | ImageBlockParam + | ToolUseBlockParam + | ToolResultBlockParam ] = [] for item in content: @@ -172,6 +201,17 @@ class AnthropicSamplingHandler: content_blocks.append( TextBlockParam(type="text", text=item.text) ) + elif isinstance(item, ImageContent): + if message.role != "user": + raise ValueError( + "ImageContent is only supported in user messages " + "for Anthropic" + ) + content_blocks.append(_image_content_to_anthropic_block(item)) + elif isinstance(item, AudioContent): + raise ValueError( + "AudioContent is not supported by the Anthropic API" + ) elif isinstance(item, ToolResultContent): # Extract text content from the result result_content: str | list[TextBlockParam] = "" @@ -262,6 +302,24 @@ class AnthropicSamplingHandler: ) continue + # Handle ImageContent + if isinstance(content, ImageContent): + if message.role != "user": + raise ValueError( + "ImageContent is only supported in user messages for Anthropic" + ) + anthropic_messages.append( + MessageParam( + role="user", + content=[_image_content_to_anthropic_block(content)], + ) + ) + continue + + # Handle AudioContent - not supported by Anthropic + if isinstance(content, AudioContent): + raise ValueError("AudioContent is not supported by the Anthropic API") + raise ValueError(f"Unsupported content type: {type(content)}") return anthropic_messages diff --git a/src/fastmcp/client/sampling/handlers/google_genai.py b/src/fastmcp/client/sampling/handlers/google_genai.py index c072d5131..ad1a3d1e8 100644 --- a/src/fastmcp/client/sampling/handlers/google_genai.py +++ b/src/fastmcp/client/sampling/handlers/google_genai.py @@ -1,11 +1,13 @@ """Google GenAI sampling handler with tool support for FastMCP 3.0.""" +import base64 from collections.abc import Sequence from uuid import uuid4 try: from google.genai import Client as GoogleGenaiClient from google.genai.types import ( + Blob, Candidate, Content, FunctionCall, @@ -197,6 +199,22 @@ def _sampling_content_to_google_genai_part( if isinstance(content, TextContent): return Part(text=content.text) + if isinstance(content, ImageContent): + return Part( + inline_data=Blob( + data=base64.b64decode(content.data), + mime_type=content.mimeType, + ) + ) + + if isinstance(content, AudioContent): + return Part( + inline_data=Blob( + data=base64.b64decode(content.data), + mime_type=content.mimeType, + ) + ) + if isinstance(content, ToolUseContent): # Note: thought_signature bypass is required for manually constructed tool calls. # Google's Gemini 3+ models enforce thought signature validation for function calls. diff --git a/src/fastmcp/client/sampling/handlers/openai.py b/src/fastmcp/client/sampling/handlers/openai.py index 38f5ff356..ffc40f158 100644 --- a/src/fastmcp/client/sampling/handlers/openai.py +++ b/src/fastmcp/client/sampling/handlers/openai.py @@ -6,10 +6,11 @@ from typing import Any, get_args from mcp import ClientSession, ServerSession from mcp.shared.context import LifespanContextT, RequestContext -from mcp.types import CreateMessageRequestParams as SamplingParams from mcp.types import ( + AudioContent, CreateMessageResult, CreateMessageResultWithTools, + ImageContent, ModelPreferences, SamplingMessage, StopReason, @@ -19,12 +20,17 @@ from mcp.types import ( ToolResultContent, ToolUseContent, ) +from mcp.types import CreateMessageRequestParams as SamplingParams try: from openai import AsyncOpenAI from openai.types.chat import ( ChatCompletion, ChatCompletionAssistantMessageParam, + ChatCompletionContentPartImageParam, + ChatCompletionContentPartInputAudioParam, + ChatCompletionContentPartParam, + ChatCompletionContentPartTextParam, ChatCompletionMessageParam, ChatCompletionMessageToolCallParam, ChatCompletionSystemMessageParam, @@ -41,6 +47,50 @@ except ImportError as e: "Please install `fastmcp[openai]` or add `openai` to your dependencies manually." ) from e +# OpenAI only supports wav and mp3 for input audio +_OPENAI_AUDIO_FORMATS: dict[str, str] = { + "audio/wav": "wav", + "audio/x-wav": "wav", + "audio/mp3": "mp3", + "audio/mpeg": "mp3", +} + +_OPENAI_IMAGE_MEDIA_TYPES: frozenset[str] = frozenset( + {"image/jpeg", "image/png", "image/gif", "image/webp"} +) + + +def _image_content_to_openai_part( + content: ImageContent, +) -> ChatCompletionContentPartImageParam: + """Convert MCP ImageContent to OpenAI image_url content part.""" + if content.mimeType not in _OPENAI_IMAGE_MEDIA_TYPES: + raise ValueError( + f"Unsupported image MIME type for OpenAI: {content.mimeType!r}. " + f"Supported types: {', '.join(sorted(_OPENAI_IMAGE_MEDIA_TYPES))}" + ) + data_url = f"data:{content.mimeType};base64,{content.data}" + return ChatCompletionContentPartImageParam( + type="image_url", + image_url={"url": data_url}, + ) + + +def _audio_content_to_openai_part( + content: AudioContent, +) -> ChatCompletionContentPartInputAudioParam: + """Convert MCP AudioContent to OpenAI input_audio content part.""" + audio_format = _OPENAI_AUDIO_FORMATS.get(content.mimeType) + if audio_format is None: + raise ValueError( + f"Unsupported audio MIME type for OpenAI: {content.mimeType!r}. " + f"Supported types: {', '.join(sorted(_OPENAI_AUDIO_FORMATS))}" + ) + return ChatCompletionContentPartInputAudioParam( + type="input_audio", + input_audio={"data": content.data, "format": audio_format}, + ) + class OpenAISamplingHandler: """Sampling handler that uses the OpenAI API.""" @@ -147,8 +197,9 @@ class OpenAISamplingHandler: # Handle list content (from CreateMessageResultWithTools) if isinstance(content, list): - # Collect tool calls and text from the list + # Collect tool calls, content parts, and text from the list tool_calls: list[ChatCompletionMessageToolCallParam] = [] + content_parts: list[ChatCompletionContentPartParam] = [] text_parts: list[str] = [] # Collect tool results separately to maintain correct ordering tool_messages: list[ChatCompletionToolMessageParam] = [] @@ -167,6 +218,15 @@ class OpenAISamplingHandler: ) elif isinstance(item, TextContent): text_parts.append(item.text) + content_parts.append( + ChatCompletionContentPartTextParam( + type="text", text=item.text + ) + ) + elif isinstance(item, ImageContent): + content_parts.append(_image_content_to_openai_part(item)) + elif isinstance(item, AudioContent): + content_parts.append(_audio_content_to_openai_part(item)) elif isinstance(item, ToolResultContent): # Collect tool results (added after assistant message) content_text = "" @@ -186,33 +246,47 @@ class OpenAISamplingHandler: # Add assistant message with tool calls if present # OpenAI requires: assistant (with tool_calls) -> tool messages - if tool_calls or text_parts: - msg_content = "\n".join(text_parts) if text_parts else None + if tool_calls or content_parts: if tool_calls: + has_multimodal = len(content_parts) > len(text_parts) + if has_multimodal: + raise ValueError( + "ImageContent/AudioContent is only supported " + "in user messages for OpenAI" + ) + text_str = "\n".join(text_parts) or None openai_messages.append( ChatCompletionAssistantMessageParam( role="assistant", - content=msg_content, + content=text_str, tool_calls=tool_calls, ) ) # Add tool messages AFTER assistant message openai_messages.extend(tool_messages) - elif msg_content: + elif content_parts: if message.role == "user": openai_messages.append( ChatCompletionUserMessageParam( role="user", - content=msg_content, + content=content_parts, ) ) else: - openai_messages.append( - ChatCompletionAssistantMessageParam( - role="assistant", - content=msg_content, + has_multimodal = len(content_parts) > len(text_parts) + if has_multimodal: + raise ValueError( + "ImageContent/AudioContent is only supported " + "in user messages for OpenAI" + ) + assistant_text = "\n".join(text_parts) + if assistant_text: + openai_messages.append( + ChatCompletionAssistantMessageParam( + role="assistant", + content=assistant_text, + ) ) - ) elif tool_messages: # Tool results only (assistant message was in previous message) openai_messages.extend(tool_messages) @@ -272,6 +346,34 @@ class OpenAISamplingHandler: ) continue + # Handle ImageContent + if isinstance(content, ImageContent): + if message.role != "user": + raise ValueError( + "ImageContent is only supported in user messages for OpenAI" + ) + openai_messages.append( + ChatCompletionUserMessageParam( + role="user", + content=[_image_content_to_openai_part(content)], + ) + ) + continue + + # Handle AudioContent + if isinstance(content, AudioContent): + if message.role != "user": + raise ValueError( + "AudioContent is only supported in user messages for OpenAI" + ) + openai_messages.append( + ChatCompletionUserMessageParam( + role="user", + content=[_audio_content_to_openai_part(content)], + ) + ) + continue + raise ValueError(f"Unsupported content type: {type(content)}") return openai_messages @@ -299,7 +401,7 @@ class OpenAISamplingHandler: ) -> ChatModel: for model_option in self._iter_models_from_preferences(model_preferences): if model_option in get_args(ChatModel): - chosen_model: ChatModel = model_option # type: ignore[assignment] + chosen_model: ChatModel = model_option # type: ignore[assignment] # ty:ignore[invalid-assignment] return chosen_model return self.default_model @@ -378,18 +480,18 @@ class OpenAISamplingHandler: func = tool_call.function # Parse the arguments JSON string try: - arguments = json.loads(func.arguments) # type: ignore[union-attr] + arguments = json.loads(func.arguments) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] except json.JSONDecodeError as e: raise ValueError( f"Invalid JSON in tool arguments for " - f"'{func.name}': {func.arguments}" # type: ignore[union-attr] + f"'{func.name}': {func.arguments}" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] ) from e content.append( ToolUseContent( type="tool_use", id=tool_call.id, - name=func.name, # type: ignore[union-attr] + name=func.name, # type: ignore[union-attr] # ty:ignore[unresolved-attribute] input=arguments, ) ) @@ -399,7 +501,7 @@ class OpenAISamplingHandler: raise ValueError("No content in response from completion") return CreateMessageResultWithTools( - content=content, # type: ignore[arg-type] + content=content, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] role="assistant", model=chat_completion.model, stopReason=stop_reason, diff --git a/src/fastmcp/client/tasks.py b/src/fastmcp/client/tasks.py index e3cfea518..ae6b0ad98 100644 --- a/src/fastmcp/client/tasks.py +++ b/src/fastmcp/client/tasks.py @@ -138,7 +138,7 @@ class Task(abc.ABC, Generic[TaskResultT]): result = callback(status) if inspect.isawaitable(result): # Fire and forget async callbacks - asyncio.create_task(result) # type: ignore[arg-type] # noqa: RUF006 + asyncio.create_task(result) # type: ignore[arg-type] # noqa: RUF006 # ty:ignore[invalid-argument-type] except Exception as e: logger.warning(f"Task callback error: {e}", exc_info=True) @@ -379,7 +379,7 @@ class ToolTask(Task["CallToolResult"]): mcp_result = mcp.types.CallToolResult( content=raw_result.content, structuredContent=raw_result.structured_content, - _meta=raw_result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + _meta=raw_result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] ) result = await self._client._parse_call_tool_result( self._tool_name, mcp_result, raise_on_error=True diff --git a/src/fastmcp/client/transports/base.py b/src/fastmcp/client/transports/base.py index e9ebb1f3a..fb8047c89 100644 --- a/src/fastmcp/client/transports/base.py +++ b/src/fastmcp/client/transports/base.py @@ -64,7 +64,7 @@ class ClientTransport(abc.ABC): A mcp.ClientSession instance. """ raise NotImplementedError - yield + yield # ty:ignore[invalid-yield] def __repr__(self) -> str: # Basic representation for subclasses diff --git a/src/fastmcp/client/transports/config.py b/src/fastmcp/client/transports/config.py index c8d5a8a40..cd1d59cfa 100644 --- a/src/fastmcp/client/transports/config.py +++ b/src/fastmcp/client/transports/config.py @@ -17,6 +17,9 @@ from fastmcp.mcp_config import ( TransformingStdioMCPServer, ) from fastmcp.server.server import FastMCP, create_proxy +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) class MCPConfigTransport(ClientTransport): @@ -104,21 +107,25 @@ class MCPConfigTransport(ClientTransport): await t.close() self._transports = [] - try: - for name, server_config in self.config.mcpServers.items(): + for name, server_config in self.config.mcpServers.items(): + try: transport, _client, proxy = await self._create_proxy( name, server_config, timeout, stack ) - self._transports.append(transport) - composite.mount( - proxy, namespace=name if self.name_as_prefix else None + except Exception: # Broad catch is intentional: failure modes + # are diverse (OSError, TimeoutError, RuntimeError, etc.) + # and the whole point is to skip any server that can't connect. + logger.warning( + "Failed to connect to MCP server %r, skipping", + name, + exc_info=True, ) - except Exception: - # Clean up any transports created before the failure - for t in self._transports: - await t.close() - self._transports = [] - raise + continue + self._transports.append(transport) + composite.mount(proxy, namespace=name if self.name_as_prefix else None) + + if not self._transports: + raise ConnectionError("All MCP servers failed to connect") async with FastMCPTransport(mcp=composite).connect_session( **session_kwargs diff --git a/src/fastmcp/client/transports/http.py b/src/fastmcp/client/transports/http.py index f7e74ca60..5ff52a091 100644 --- a/src/fastmcp/client/transports/http.py +++ b/src/fastmcp/client/transports/http.py @@ -4,8 +4,9 @@ from __future__ import annotations import contextlib import datetime +import ssl from collections.abc import AsyncIterator, Callable -from typing import Literal, cast +from typing import Any, Literal, cast import httpx from mcp import ClientSession @@ -18,6 +19,7 @@ import fastmcp from fastmcp.client.auth.bearer import BearerAuth from fastmcp.client.auth.oauth import OAuth from fastmcp.client.transports.base import ClientTransport, SessionKwargs +from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.server.dependencies import get_http_headers from fastmcp.utilities.timeout import normalize_timeout_to_timedelta @@ -32,6 +34,7 @@ class StreamableHttpTransport(ClientTransport): auth: httpx.Auth | Literal["oauth"] | str | None = None, sse_read_timeout: datetime.timedelta | float | int | None = None, httpx_client_factory: McpHttpClientFactory | None = None, + verify: ssl.SSLContext | bool | str | None = None, ): """Initialize a Streamable HTTP transport. @@ -45,6 +48,10 @@ class StreamableHttpTransport(ClientTransport): If provided, must accept keyword arguments: headers, auth, follow_redirects, and optionally timeout. Using **kwargs is recommended to ensure forward compatibility. + verify: SSL certificate verification. Accepts False to disable + verification, a path to a CA bundle, or an ssl.SSLContext + for full control. None (default) uses httpx defaults (verification + enabled). Ignored when httpx_client_factory is provided. """ if isinstance(url, AnyUrl): url = str(url) @@ -57,6 +64,20 @@ class StreamableHttpTransport(ClientTransport): self.url: str = url self.headers = headers or {} self.httpx_client_factory = httpx_client_factory + self.verify: ssl.SSLContext | bool | str | None = verify + + if httpx_client_factory is not None and verify is not None: + import warnings + + warnings.warn( + "Both 'httpx_client_factory' and 'verify' were provided. " + "The 'verify' parameter will be ignored because " + "'httpx_client_factory' takes precedence. Configure SSL " + "verification directly in your httpx_client_factory instead.", + UserWarning, + stacklevel=2, + ) + self._set_auth(auth) if sse_read_timeout is not None: @@ -68,7 +89,7 @@ class StreamableHttpTransport(ClientTransport): "The new streamable_http_client API does not support this parameter. " "Use `read_timeout_seconds` in session_kwargs or configure timeout on " "the httpx client via `httpx_client_factory` instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout) @@ -78,9 +99,19 @@ class StreamableHttpTransport(ClientTransport): def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): resolved: httpx.Auth | None if auth == "oauth": - resolved = OAuth(self.url, httpx_client_factory=self.httpx_client_factory) + resolved = OAuth( + self.url, + httpx_client_factory=self.httpx_client_factory + or self._make_verify_factory(), + ) elif isinstance(auth, OAuth): auth._bind(self.url) + # Only inject the transport's factory into OAuth if OAuth still + # has the bare default — preserve any factory the caller attached + if auth.httpx_client_factory is httpx.AsyncClient: + factory = self.httpx_client_factory or self._make_verify_factory() + if factory is not None: + auth.httpx_client_factory = factory resolved = auth elif isinstance(auth, str): resolved = BearerAuth(auth) @@ -88,6 +119,31 @@ class StreamableHttpTransport(ClientTransport): resolved = auth self.auth: httpx.Auth | None = resolved + def _make_verify_factory(self) -> McpHttpClientFactory | None: + if self.verify is None: + return None + verify = self.verify + + def factory( + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, + ) -> httpx.AsyncClient: + if timeout is None: + timeout = httpx.Timeout(30.0, read=300.0) + kwargs: dict[str, Any] = { + "follow_redirects": True, + "timeout": timeout, + "verify": verify, + } + if headers is not None: + kwargs["headers"] = headers + if auth is not None: + kwargs["auth"] = auth + return httpx.AsyncClient(**kwargs) + + return cast(McpHttpClientFactory, factory) + @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] @@ -105,17 +161,24 @@ class StreamableHttpTransport(ClientTransport): ) timeout = httpx.Timeout(30.0, read=read_timeout_seconds.total_seconds()) - # Create httpx client from factory or use default with MCP-appropriate timeouts - # create_mcp_http_client uses 30s connect/5min read timeout by default, - # and always enables follow_redirects + # Create httpx client from factory or use default with MCP-appropriate + # timeouts. Note: create_mcp_http_client enables follow_redirects, but + # httpx automatically strips Authorization headers on cross-origin + # redirects to prevent credential leakage. + verify_factory = self._make_verify_factory() if self.httpx_client_factory is not None: - # Factory clients get the full kwargs for backwards compatibility http_client = self.httpx_client_factory( headers=headers, auth=self.auth, - follow_redirects=True, # type: ignore[call-arg] + follow_redirects=True, # type: ignore[call-arg] # ty:ignore[unknown-argument] **({"timeout": timeout} if timeout else {}), ) + elif verify_factory is not None: + http_client = verify_factory( + headers=headers, + timeout=timeout, + auth=self.auth, + ) else: http_client = create_mcp_http_client( headers=headers, diff --git a/src/fastmcp/client/transports/memory.py b/src/fastmcp/client/transports/memory.py index 26b4e934a..7b2cbc8e4 100644 --- a/src/fastmcp/client/transports/memory.py +++ b/src/fastmcp/client/transports/memory.py @@ -45,30 +45,37 @@ class FastMCPTransport(ClientTransport): # is called during cleanup, so we capture and re-raise manually. exception_to_raise: BaseException | None = None - async with ( - anyio.create_task_group() as tg, - _enter_server_lifespan(server=self.server), - ): - tg.start_soon( - lambda: self.server._mcp_server.run( - server_read, - server_write, - self.server._mcp_server.create_initialization_options(), - raise_exceptions=self.raise_exceptions, + # IMPORTANT: The lifespan MUST be the outer context and the task + # group MUST be the inner context. This ensures the task group + # (containing the server's run() and all its pub/sub subscriptions) + # is cancelled and fully drained BEFORE the lifespan tears down + # the Docket Worker and closes Redis connections. Reversing this + # order (e.g. via `async with (tg, lifespan):`) causes the Worker + # shutdown to hang for 5 seconds per test because fakeredis + # blocking operations hold references that prevent clean + # cancellation. + async with _enter_server_lifespan(server=self.server): # noqa: SIM117 + async with anyio.create_task_group() as tg: + tg.start_soon( + lambda: self.server._mcp_server.run( + server_read, + server_write, + self.server._mcp_server.create_initialization_options(), + raise_exceptions=self.raise_exceptions, + ) ) - ) - try: - async with ClientSession( - read_stream=client_read, - write_stream=client_write, - **session_kwargs, - ) as client_session: - yield client_session - except BaseException as e: - exception_to_raise = e - finally: - tg.cancel_scope.cancel() + try: + async with ClientSession( + read_stream=client_read, + write_stream=client_write, + **session_kwargs, + ) as client_session: + yield client_session + except BaseException as e: + exception_to_raise = e + finally: + tg.cancel_scope.cancel() # Re-raise after task group has exited cleanly if exception_to_raise is not None: diff --git a/src/fastmcp/client/transports/sse.py b/src/fastmcp/client/transports/sse.py index 36fa0ebc0..fa900eb7b 100644 --- a/src/fastmcp/client/transports/sse.py +++ b/src/fastmcp/client/transports/sse.py @@ -4,6 +4,7 @@ from __future__ import annotations import contextlib import datetime +import ssl from collections.abc import AsyncIterator from typing import Any, Literal, cast @@ -31,6 +32,7 @@ class SSETransport(ClientTransport): auth: httpx.Auth | Literal["oauth"] | str | None = None, sse_read_timeout: datetime.timedelta | float | int | None = None, httpx_client_factory: McpHttpClientFactory | None = None, + verify: ssl.SSLContext | bool | str | None = None, ): if isinstance(url, AnyUrl): url = str(url) @@ -43,6 +45,20 @@ class SSETransport(ClientTransport): self.url: str = url self.headers = headers or {} self.httpx_client_factory = httpx_client_factory + self.verify: ssl.SSLContext | bool | str | None = verify + + if httpx_client_factory is not None and verify is not None: + import warnings + + warnings.warn( + "Both 'httpx_client_factory' and 'verify' were provided. " + "The 'verify' parameter will be ignored because " + "'httpx_client_factory' takes precedence. Configure SSL " + "verification directly in your httpx_client_factory instead.", + UserWarning, + stacklevel=2, + ) + self._set_auth(auth) self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout) @@ -50,9 +66,19 @@ class SSETransport(ClientTransport): def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): resolved: httpx.Auth | None if auth == "oauth": - resolved = OAuth(self.url, httpx_client_factory=self.httpx_client_factory) + resolved = OAuth( + self.url, + httpx_client_factory=self.httpx_client_factory + or self._make_verify_factory(), + ) elif isinstance(auth, OAuth): auth._bind(self.url) + # Only inject the transport's factory into OAuth if OAuth still + # has the bare default — preserve any factory the caller attached + if auth.httpx_client_factory is httpx.AsyncClient: + factory = self.httpx_client_factory or self._make_verify_factory() + if factory is not None: + auth.httpx_client_factory = factory resolved = auth elif isinstance(auth, str): resolved = BearerAuth(auth) @@ -60,6 +86,31 @@ class SSETransport(ClientTransport): resolved = auth self.auth: httpx.Auth | None = resolved + def _make_verify_factory(self) -> McpHttpClientFactory | None: + if self.verify is None: + return None + verify = self.verify + + def factory( + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, + ) -> httpx.AsyncClient: + if timeout is None: + timeout = httpx.Timeout(30.0, read=300.0) + kwargs: dict[str, Any] = { + "follow_redirects": True, + "timeout": timeout, + "verify": verify, + } + if headers is not None: + kwargs["headers"] = headers + if auth is not None: + kwargs["auth"] = auth + return httpx.AsyncClient(**kwargs) + + return cast(McpHttpClientFactory, factory) + @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] @@ -85,6 +136,10 @@ class SSETransport(ClientTransport): if self.httpx_client_factory is not None: client_kwargs["httpx_client_factory"] = self.httpx_client_factory + else: + verify_factory = self._make_verify_factory() + if verify_factory is not None: + client_kwargs["httpx_client_factory"] = verify_factory async with sse_client(self.url, auth=self.auth, **client_kwargs) as transport: read_stream, write_stream = transport diff --git a/src/fastmcp/client/transports/stdio.py b/src/fastmcp/client/transports/stdio.py index ca8d5377c..d772c3a88 100644 --- a/src/fastmcp/client/transports/stdio.py +++ b/src/fastmcp/client/transports/stdio.py @@ -84,6 +84,13 @@ class StdioTransport(ClientTransport): async def connect( self, **session_kwargs: Unpack[SessionKwargs] ) -> ClientSession | None: + # If the connect task completed or the session's streams are dead, + # the subprocess has exited. Tear down so we can start fresh. + if self._connect_task is not None and ( + self._connect_task.done() or self._is_session_dead() + ): + await self.disconnect() + if self._connect_task is not None: return @@ -98,7 +105,7 @@ class StdioTransport(ClientTransport): cwd=self.cwd, log_file=self.log_file, # TODO(ty): remove when ty supports Unpack[TypedDict] inference - session_kwargs=session_kwargs, # type: ignore[arg-type] + session_kwargs=session_kwargs, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] ready_event=self._ready_event, stop_event=self._stop_event, session_future=session_future, @@ -125,13 +132,33 @@ class StdioTransport(ClientTransport): self._stop_event.set() # wait for the connection task to finish cleanly - await self._connect_task + with contextlib.suppress(Exception): + await self._connect_task # reset variables and events for potential future reconnects self._connect_task = None + self._session = None self._stop_event = anyio.Event() self._ready_event = anyio.Event() + def _is_session_dead(self) -> bool: + """Check if the session's underlying streams have been closed. + + Checks both the write stream (stdin to subprocess) and the read + stream (stdout from subprocess). On some platforms the write-side + pipe lingers after the process exits, so the read-side check + (which reflects stdout_reader detecting the dead process) is the + more reliable signal. + """ + if self._session is None: + return False + try: + if self._session._write_stream.statistics().open_send_streams == 0: + return True + return self._session._read_stream.statistics().open_send_streams == 0 + except AttributeError: + return False + async def close(self): await self.disconnect() diff --git a/src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py b/src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py index 07e82e8aa..71ed25482 100644 --- a/src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py +++ b/src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py @@ -1,6 +1,6 @@ from typing import Any -from mcp.types import CallToolResult +from mcp.types import CallToolResult, TextContent from pydantic import BaseModel, Field from fastmcp import FastMCP @@ -53,6 +53,8 @@ class BulkToolCaller(MCPMixin): A class to provide a "bulk tool call" tool for a FastMCP server """ + _BULK_TOOL_NAMES: frozenset[str] = frozenset({"call_tools_bulk", "call_tool_bulk"}) + def register_tools( self, mcp_server: "FastMCP", @@ -122,6 +124,22 @@ class BulkToolCaller(MCPMixin): Helper method to call a tool with the provided arguments. """ + if tool in self._BULK_TOOL_NAMES: + return CallToolRequestResult( + tool=tool, + arguments=arguments, + isError=True, + content=[ + TextContent( + type="text", + text=( + "BulkToolCaller cannot call itself. " + "The tools 'call_tools_bulk' and 'call_tool_bulk' are disallowed." + ), + ) + ], + ) + async with Client(self.connection) as client: result = await client.call_tool_mcp(name=tool, arguments=arguments) diff --git a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py index 86f8c9aec..f783f7748 100644 --- a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py +++ b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py @@ -6,9 +6,10 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any import fastmcp -from fastmcp.prompts.prompt import Prompt -from fastmcp.resources.resource import Resource -from fastmcp.tools.tool import Tool +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.prompts.base import Prompt +from fastmcp.resources.base import Resource +from fastmcp.tools.base import Tool from fastmcp.utilities.types import get_fn_name if TYPE_CHECKING: @@ -77,7 +78,7 @@ def mcp_tool( "The `serializer` parameter is deprecated. " "Return ToolResult from your tools for full control over serialization. " "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) diff --git a/src/fastmcp/exceptions.py b/src/fastmcp/exceptions.py index 595961e3b..e5e079023 100644 --- a/src/fastmcp/exceptions.py +++ b/src/fastmcp/exceptions.py @@ -3,6 +3,15 @@ from mcp import McpError # noqa: F401 +class FastMCPDeprecationWarning(DeprecationWarning): + """Deprecation warning for FastMCP APIs. + + Subclass of DeprecationWarning so that standard warning filters + still apply, but FastMCP can selectively enable its own warnings + without affecting other libraries in the process. + """ + + class FastMCPError(Exception): """Base error for FastMCP.""" diff --git a/src/fastmcp/experimental/server/openapi/__init__.py b/src/fastmcp/experimental/server/openapi/__init__.py index 827a9ff81..b19563400 100644 --- a/src/fastmcp/experimental/server/openapi/__init__.py +++ b/src/fastmcp/experimental/server/openapi/__init__.py @@ -2,11 +2,13 @@ import warnings +from fastmcp.exceptions import FastMCPDeprecationWarning + # Deprecated in 2.14 when OpenAPI support was promoted out of experimental warnings.warn( "Importing from fastmcp.experimental.server.openapi is deprecated. " "Import from fastmcp.server.providers.openapi instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) diff --git a/src/fastmcp/experimental/transforms/code_mode.py b/src/fastmcp/experimental/transforms/code_mode.py index 1ebc3da35..57644239c 100644 --- a/src/fastmcp/experimental/transforms/code_mode.py +++ b/src/fastmcp/experimental/transforms/code_mode.py @@ -1,4 +1,3 @@ -import asyncio import importlib import json from collections.abc import Awaitable, Callable, Sequence @@ -15,7 +14,8 @@ from fastmcp.server.transforms.search.base import ( serialize_tools_for_output_json, serialize_tools_for_output_markdown, ) -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult +from fastmcp.utilities.async_utils import is_coroutine_function from fastmcp.utilities.versions import VersionSpec # --------------------------------------------------------------------------- @@ -38,7 +38,7 @@ DiscoveryToolFactory = Callable[[GetToolCatalog], Tool] def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]: - if asyncio.iscoroutinefunction(fn): + if is_coroutine_function(fn): return fn async def wrapper(*args: Any, **kwargs: Any) -> Any: @@ -130,7 +130,6 @@ class MontySandboxProvider: monty = pydantic_monty.Monty( code, inputs=list(inputs.keys()), - external_functions=list(async_functions.keys()), ) run_kwargs: dict[str, Any] = {"external_functions": async_functions} if inputs: @@ -229,7 +228,7 @@ class Search: int | None, "Maximum number of results to return", ] = default_limit, - ctx: Context = None, # type: ignore[assignment] + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] ) -> str: """Search for available tools by query. @@ -292,7 +291,7 @@ class GetSchemas: ToolDetailLevel, "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", ] = default_detail, - ctx: Context = None, # type: ignore[assignment] + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] ) -> str: """Get parameter schemas for specific tools. @@ -350,7 +349,7 @@ class GetTags: Literal["brief", "full"], "Level of detail: 'brief' for tag names and counts, 'full' for tools listed under each tag", ] = default_detail, - ctx: Context = None, # type: ignore[assignment] + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] ) -> str: """List available tool tags. @@ -415,7 +414,7 @@ class ListTools: ToolDetailLevel, "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", ] = default_detail, - ctx: Context = None, # type: ignore[assignment] + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] ) -> str: """List all available tools. @@ -538,7 +537,7 @@ class CodeMode(CatalogTransform): ) ), ], - ctx: Context = None, # type: ignore[assignment] + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] ) -> Any: """Execute tool calls using Python code.""" diff --git a/src/fastmcp/experimental/utilities/openapi/__init__.py b/src/fastmcp/experimental/utilities/openapi/__init__.py index 0c8dd15e5..cdba7dda8 100644 --- a/src/fastmcp/experimental/utilities/openapi/__init__.py +++ b/src/fastmcp/experimental/utilities/openapi/__init__.py @@ -2,6 +2,8 @@ import warnings +from fastmcp.exceptions import FastMCPDeprecationWarning + from fastmcp.utilities.openapi import ( HTTPRoute, HttpMethod, @@ -18,7 +20,7 @@ from fastmcp.utilities.openapi import ( warnings.warn( "Importing from fastmcp.experimental.utilities.openapi is deprecated. " "Import from fastmcp.utilities.openapi instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) diff --git a/src/fastmcp/prompts/__init__.py b/src/fastmcp/prompts/__init__.py index 7148c052d..d1b866075 100644 --- a/src/fastmcp/prompts/__init__.py +++ b/src/fastmcp/prompts/__init__.py @@ -1,5 +1,13 @@ +import sys + from .function_prompt import FunctionPrompt, prompt -from .prompt import Message, Prompt, PromptArgument, PromptMessage, PromptResult +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", diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/base.py similarity index 95% rename from src/fastmcp/prompts/prompt.py rename to src/fastmcp/prompts/base.py index 07540629e..db399bde2 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/base.py @@ -17,8 +17,10 @@ if TYPE_CHECKING: import mcp.types from mcp import GetPromptResult from mcp.types import ( + AudioContent, EmbeddedResource, Icon, + ImageContent, PromptMessage, TextContent, ) @@ -27,6 +29,7 @@ from mcp.types import PromptArgument as SDKPromptArgument from pydantic import Field from pydantic.json_schema import SkipJsonSchema +from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.utilities.components import FastMCPComponent @@ -61,7 +64,7 @@ class Message(pydantic.BaseModel): """ role: Literal["user", "assistant"] - content: TextContent | EmbeddedResource + content: TextContent | ImageContent | AudioContent | EmbeddedResource def __init__( self, @@ -72,13 +75,18 @@ class Message(pydantic.BaseModel): Args: content: The message content. str passes through directly. - TextContent and EmbeddedResource pass through. + TextContent, ImageContent, AudioContent, and + EmbeddedResource pass through. Other types (dict, list, BaseModel) are JSON-serialized. role: The message role, either "user" or "assistant". """ # Handle already-wrapped content types - if isinstance(content, (TextContent, EmbeddedResource)): - normalized_content: TextContent | EmbeddedResource = content + if isinstance( + content, (TextContent, ImageContent, AudioContent, EmbeddedResource) + ): + normalized_content: ( + TextContent | ImageContent | AudioContent | EmbeddedResource + ) = content elif isinstance(content, str): normalized_content = TextContent(type="text", text=content) else: @@ -183,7 +191,7 @@ class PromptResult(pydantic.BaseModel): return GetPromptResult( description=self.description, messages=mcp_messages, - _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] ) @@ -221,7 +229,7 @@ class Prompt(FastMCPComponent): icons=overrides.get("icons", self.icons), _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field "_meta", self.get_meta() - ), + ), # ty:ignore[unknown-argument] ) @classmethod @@ -422,7 +430,7 @@ def __getattr__(name: str) -> Any: warnings.warn( f"Importing {name} from fastmcp.prompts.prompt is deprecated. " f"Import from fastmcp.prompts.function_prompt instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) from fastmcp.prompts import function_prompt diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py index a58700a01..b5ddd3425 100644 --- a/src/fastmcp/prompts/function_prompt.py +++ b/src/fastmcp/prompts/function_prompt.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import inspect import json import warnings @@ -23,15 +24,18 @@ from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config -from fastmcp.exceptions import PromptError -from fastmcp.prompts.prompt import Prompt, PromptArgument, PromptResult +from fastmcp.exceptions import FastMCPDeprecationWarning, PromptError +from fastmcp.prompts.base import Prompt, PromptArgument, PromptResult from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.dependencies import ( transform_context_annotations, without_injected_parameters, ) from fastmcp.server.tasks.config import TaskConfig -from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool +from fastmcp.utilities.async_utils import ( + call_sync_fn_in_threadpool, + is_coroutine_function, +) from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import get_cached_typeadapter @@ -161,7 +165,7 @@ class FunctionPrompt(Prompt): task_config.validate_function(fn, func_name) # if the fn is a callable class, we need to get the __call__ method from here out - if not inspect.isroutine(fn): + if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): fn = fn.__call__ # if the fn is a staticmethod, we need to work with the underlying function if isinstance(fn, staticmethod): @@ -312,7 +316,7 @@ class FunctionPrompt(Prompt): # self.fn is wrapped by without_injected_parameters which handles # dependency resolution internally - if inspect.iscoroutinefunction(self.fn): + if is_coroutine_function(self.fn): result = await type_adapter.validate_python(kwargs) else: # Run sync functions in threadpool to avoid blocking the event loop @@ -457,10 +461,10 @@ def prompt( warnings.warn( "decorator_mode='object' is deprecated and will be removed in a future version. " "Decorators now return the original function with metadata attached.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=4, ) - return create_prompt(fn, prompt_name) # type: ignore[return-value] + return create_prompt(fn, prompt_name) # type: ignore[return-value] # ty:ignore[invalid-return-type] return attach_metadata(fn, prompt_name) if inspect.isroutine(name_or_fn): diff --git a/src/fastmcp/resources/__init__.py b/src/fastmcp/resources/__init__.py index 68785c144..cbcfff785 100644 --- a/src/fastmcp/resources/__init__.py +++ b/src/fastmcp/resources/__init__.py @@ -1,5 +1,7 @@ +import sys + from .function_resource import FunctionResource, resource -from .resource import Resource, ResourceContent, ResourceResult +from .base import Resource, ResourceContent, ResourceResult from .template import ResourceTemplate from .types import ( BinaryResource, @@ -22,3 +24,9 @@ __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/src/fastmcp/resources/resource.py b/src/fastmcp/resources/base.py similarity index 97% rename from src/fastmcp/resources/resource.py rename to src/fastmcp/resources/base.py index 26ed535de..bd86459b8 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/base.py @@ -29,6 +29,7 @@ from pydantic import ( from pydantic.json_schema import SkipJsonSchema from typing_extensions import Self +from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.utilities.components import FastMCPComponent @@ -105,14 +106,14 @@ class ResourceContent(pydantic.BaseModel): uri=AnyUrl(uri) if isinstance(uri, str) else uri, text=self.content, mimeType=self.mime_type or "text/plain", - _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] ) else: return mcp.types.BlobResourceContents( uri=AnyUrl(uri) if isinstance(uri, str) else uri, blob=base64.b64encode(self.content).decode(), mimeType=self.mime_type or "application/octet-stream", - _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] ) @@ -203,7 +204,7 @@ class ResourceResult(pydantic.BaseModel): mcp_contents = [item.to_mcp_resource_contents(uri) for item in self.contents] return mcp.types.ReadResourceResult( contents=mcp_contents, - _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] ) @@ -387,7 +388,7 @@ class Resource(FastMCPComponent): annotations=overrides.get("annotations", self.annotations), _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field "_meta", self.get_meta() - ), + ), # ty:ignore[unknown-argument] ) def __repr__(self) -> str: @@ -456,7 +457,7 @@ def __getattr__(name: str) -> Any: warnings.warn( f"Importing {name} from fastmcp.resources.resource is deprecated. " f"Import from fastmcp.resources.function_resource instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) from fastmcp.resources import function_resource diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py index bf6673552..771eeb7cb 100644 --- a/src/fastmcp/resources/function_resource.py +++ b/src/fastmcp/resources/function_resource.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import inspect import warnings from collections.abc import Callable @@ -14,15 +15,19 @@ from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config -from fastmcp.resources.resource import Resource, ResourceResult -from fastmcp.server.apps import resolve_ui_mime_type +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.resources.base import Resource, ResourceResult from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.dependencies import ( transform_context_annotations, without_injected_parameters, ) from fastmcp.server.tasks.config import TaskConfig -from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool +from fastmcp.utilities.async_utils import ( + call_sync_fn_in_threadpool, + is_coroutine_function, +) +from fastmcp.utilities.mime import resolve_ui_mime_type if TYPE_CHECKING: from docket import Docket @@ -170,7 +175,7 @@ class FunctionResource(Resource): task_config.validate_function(fn, func_name) # if the fn is a callable class, we need to get the __call__ method from here out - if not inspect.isroutine(fn): + if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): fn = fn.__call__ # if the fn is a staticmethod, we need to work with the underlying function if isinstance(fn, staticmethod): @@ -207,7 +212,7 @@ class FunctionResource(Resource): """Read the resource by calling the wrapped function.""" # self.fn is wrapped by without_injected_parameters which handles # dependency resolution internally - if inspect.iscoroutinefunction(self.fn): + if is_coroutine_function(self.fn): result = await self.fn() else: # Run sync functions in threadpool to avoid blocking the event loop @@ -331,10 +336,10 @@ def resource( warnings.warn( "decorator_mode='object' is deprecated and will be removed in a future version. " "Decorators now return the original function with metadata attached.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=3, ) - return create_resource(fn) # type: ignore[return-value] + return create_resource(fn) # type: ignore[return-value] # ty:ignore[invalid-return-type] return attach_metadata(fn) return decorator diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index c2fb1b622..3a3180c77 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import inspect import re from collections.abc import Callable @@ -22,8 +23,7 @@ from pydantic import ( validate_call, ) -from fastmcp.resources.resource import Resource, ResourceResult -from fastmcp.server.apps import resolve_ui_mime_type +from fastmcp.resources.base import Resource, ResourceResult from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.dependencies import ( transform_context_annotations, @@ -32,6 +32,7 @@ from fastmcp.server.dependencies import ( from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.json_schema import compress_schema +from fastmcp.utilities.mime import resolve_ui_mime_type from fastmcp.utilities.types import get_cached_typeadapter @@ -43,13 +44,16 @@ def extract_query_params(uri_template: str) -> set[str]: return set() -def build_regex(template: str) -> re.Pattern: +def build_regex(template: str) -> re.Pattern[str] | None: """Build regex pattern for URI template, handling RFC 6570 syntax. Supports: - `{var}` - simple path parameter - `{var*}` - wildcard path parameter (captures multiple segments) - `{?var1,var2}` - query parameters (ignored in path matching) + + Returns None if the template produces an invalid regex (e.g. parameter + names with hyphens, leading digits, or duplicates from a remote server). """ # Remove query parameter syntax for path matching template_without_query = re.sub(r"\{\?[^}]+\}", "", template) @@ -66,7 +70,10 @@ def build_regex(template: str) -> re.Pattern: pattern += f"(?P<{name}>[^/]+)" else: pattern += re.escape(part) - return re.compile(f"^{pattern}$") + try: + return re.compile(f"^{pattern}$") + except re.error: + return None def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None: @@ -81,6 +88,8 @@ def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None: # Match path parameters regex = build_regex(uri_template) + if regex is None: + return None match = regex.match(uri_path) if not match: return None @@ -267,7 +276,7 @@ class ResourceTemplate(FastMCPComponent): annotations=overrides.get("annotations", self.annotations), _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field "_meta", self.get_meta() - ), + ), # ty:ignore[unknown-argument] ) @classmethod @@ -409,9 +418,17 @@ class FunctionResourceTemplate(ResourceTemplate): elif annotation is float: kwargs[param_name] = float(param_value) elif annotation is bool: - kwargs[param_name] = param_value.lower() in ("true", "1", "yes") + lower = param_value.lower() + if lower in ("true", "1", "yes"): + kwargs[param_name] = True + elif lower in ("false", "0", "no"): + kwargs[param_name] = False + else: + raise ValueError( + f"Invalid boolean value for {param_name}: {param_value!r}" + ) except (ValueError, AttributeError): - pass + raise # self.fn is wrapped by without_injected_parameters which handles # dependency resolution internally, so we call it directly @@ -552,7 +569,7 @@ class FunctionResourceTemplate(ResourceTemplate): task_config.validate_function(fn, func_name) # if the fn is a callable class, we need to get the __call__ method from here out - if not inspect.isroutine(fn): + if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): fn = fn.__call__ # if the fn is a staticmethod, we need to work with the underlying function if isinstance(fn, staticmethod): diff --git a/src/fastmcp/resources/types.py b/src/fastmcp/resources/types.py index 30642683b..ed514c5ce 100644 --- a/src/fastmcp/resources/types.py +++ b/src/fastmcp/resources/types.py @@ -12,7 +12,7 @@ from pydantic import Field, ValidationInfo from typing_extensions import override from fastmcp.exceptions import ResourceError -from fastmcp.resources.resource import Resource, ResourceContent, ResourceResult +from fastmcp.resources.base import Resource, ResourceContent, ResourceResult from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -65,6 +65,14 @@ class FileResource(Resource): default="text/plain", description="MIME type of the resource content", ) + encoding: str | None = Field( + default="utf-8", + description=( + "Encoding to use when reading text files. " + "Defaults to 'utf-8' for cross-platform compatibility. " + "Set to None to use the system default encoding." + ), + ) @property def _async_path(self) -> AsyncPath: @@ -94,7 +102,7 @@ class FileResource(Resource): if self.is_binary: content: str | bytes = await self._async_path.read_bytes() else: - content = await self._async_path.read_text() + content = await self._async_path.read_text(encoding=self.encoding) return ResourceResult( contents=[ResourceContent(content=content, mime_type=self.mime_type)] ) diff --git a/src/fastmcp/server/app.py b/src/fastmcp/server/app.py new file mode 100644 index 000000000..ad15d59b1 --- /dev/null +++ b/src/fastmcp/server/app.py @@ -0,0 +1,19 @@ +"""Backward-compatible re-exports from fastmcp.apps.app. + +.. deprecated:: 3.2.0 + Import from ``fastmcp.apps.app`` or ``fastmcp`` instead. +""" + +import warnings + +from fastmcp.apps.app import FastMCPApp as FastMCPApp +from fastmcp.apps.app import _dispatch_decorator as _dispatch_decorator +from fastmcp.apps.app import _make_resolver as _make_resolver +from fastmcp.exceptions import FastMCPDeprecationWarning + +warnings.warn( + "'fastmcp.server.app' is deprecated. " + "Use 'fastmcp.apps.app' or 'from fastmcp import FastMCPApp' instead.", + FastMCPDeprecationWarning, + stacklevel=2, +) diff --git a/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py index fcb0b673c..57e95ebf7 100644 --- a/src/fastmcp/server/apps.py +++ b/src/fastmcp/server/apps.py @@ -1,142 +1,22 @@ -"""MCP Apps support — extension negotiation and typed UI metadata models. +"""Backward-compatible re-exports from fastmcp.apps. -Provides constants and Pydantic models for the MCP Apps extension -(io.modelcontextprotocol/ui), enabling tools and resources to carry -UI metadata for clients that support interactive app rendering. +.. deprecated:: 3.2.0 + Import from ``fastmcp.apps`` instead. """ -from __future__ import annotations +import warnings -from typing import Any, Literal +from fastmcp.apps.config import UI_EXTENSION_ID as UI_EXTENSION_ID +from fastmcp.apps.config import AppConfig as AppConfig +from fastmcp.apps.config import ResourceCSP as ResourceCSP +from fastmcp.apps.config import ResourcePermissions as ResourcePermissions +from fastmcp.apps.config import app_config_to_meta_dict as app_config_to_meta_dict +from fastmcp.exceptions import FastMCPDeprecationWarning +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 -from pydantic import BaseModel, Field - -UI_EXTENSION_ID = "io.modelcontextprotocol/ui" -UI_MIME_TYPE = "text/html;profile=mcp-app" - - -class ResourceCSP(BaseModel): - """Content Security Policy for MCP App resources. - - Declares which external origins the app is allowed to connect to or - load resources from. Hosts use these declarations to build the - ``Content-Security-Policy`` header for the sandboxed iframe. - """ - - connect_domains: list[str] | None = Field( - default=None, - alias="connectDomains", - description="Origins allowed for fetch/XHR/WebSocket (connect-src)", - ) - resource_domains: list[str] | None = Field( - default=None, - alias="resourceDomains", - description="Origins allowed for scripts, images, styles, fonts (script-src etc.)", - ) - frame_domains: list[str] | None = Field( - default=None, - alias="frameDomains", - description="Origins allowed for nested iframes (frame-src)", - ) - base_uri_domains: list[str] | None = Field( - default=None, - alias="baseUriDomains", - description="Allowed base URIs for the document (base-uri)", - ) - - model_config = {"populate_by_name": True, "extra": "allow"} - - -class ResourcePermissions(BaseModel): - """Iframe sandbox permissions for MCP App resources. - - Each field, when set (typically to ``{}``), requests that the host - grant the corresponding Permission Policy feature to the sandboxed - iframe. Hosts MAY honour these; apps should use JS feature detection - as a fallback. - """ - - camera: dict[str, Any] | None = Field( - default=None, description="Request camera access" - ) - microphone: dict[str, Any] | None = Field( - default=None, description="Request microphone access" - ) - geolocation: dict[str, Any] | None = Field( - default=None, description="Request geolocation access" - ) - clipboard_write: dict[str, Any] | None = Field( - default=None, - alias="clipboardWrite", - description="Request clipboard-write access", - ) - - model_config = {"populate_by_name": True, "extra": "allow"} - - -class AppConfig(BaseModel): - """Configuration for MCP App tools and resources. - - Controls how a tool or resource participates in the MCP Apps extension. - On tools, ``resource_uri`` and ``visibility`` specify which UI resource - to render and where the tool appears. On resources, those fields must - be left unset (the resource itself is the UI). - - All fields use ``exclude_none`` serialization so only explicitly-set - values appear on the wire. Aliases match the MCP Apps wire format - (camelCase). - """ - - resource_uri: str | None = Field( - default=None, - alias="resourceUri", - description="URI of the UI resource (typically ui:// scheme). Tools only.", - ) - visibility: list[Literal["app", "model"]] | None = Field( - default=None, - description="Where this tool is visible: 'app', 'model', or both. Tools only.", - ) - csp: ResourceCSP | None = Field( - default=None, description="Content Security Policy for the app iframe" - ) - permissions: ResourcePermissions | None = Field( - default=None, description="Iframe sandbox permissions" - ) - domain: str | None = Field(default=None, description="Domain for the iframe") - prefers_border: bool | None = Field( - default=None, - alias="prefersBorder", - description="Whether the UI prefers a visible border", - ) - - model_config = {"populate_by_name": True, "extra": "allow"} - - -def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]: - """Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``.""" - if isinstance(app, AppConfig): - return app.model_dump(by_alias=True, exclude_none=True) - return app - - -def resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None: - """Return the appropriate MIME type for a resource URI. - - For ``ui://`` scheme resources, defaults to ``UI_MIME_TYPE`` when no - explicit MIME type is provided. This ensures UI resources are correctly - identified regardless of how they're registered (via FastMCP.resource, - the standalone @resource decorator, or resource templates). - - Args: - uri: The resource URI string - explicit_mime_type: The MIME type explicitly provided by the user - - Returns: - The resolved MIME type (explicit value, UI default, or None) - """ - if explicit_mime_type is not None: - return explicit_mime_type - # Case-insensitive scheme check per RFC 3986 - if uri.lower().startswith("ui://"): - return UI_MIME_TYPE - return None +warnings.warn( + "'fastmcp.server.apps' is deprecated. Use 'from fastmcp.apps import ...' instead.", + FastMCPDeprecationWarning, + stacklevel=2, +) diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index 873c1945a..3903d513f 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -321,7 +321,6 @@ class AuthProvider(TokenVerifierProtocol): Returns: List of Starlette Middleware instances to apply to the HTTP app """ - # TODO(ty): remove type ignores when ty supports Starlette Middleware typing return [ Middleware( AuthenticationMiddleware, # type: ignore[arg-type] diff --git a/src/fastmcp/server/auth/authorization.py b/src/fastmcp/server/auth/authorization.py index 8455b81f5..64eb5f721 100644 --- a/src/fastmcp/server/auth/authorization.py +++ b/src/fastmcp/server/auth/authorization.py @@ -40,7 +40,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from fastmcp.server.auth import AccessToken - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent @@ -66,7 +66,7 @@ class AuthContext: Returns the component if it's a Tool, None otherwise. """ - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool return self.component if isinstance(self.component, Tool) else None diff --git a/src/fastmcp/server/auth/cimd.py b/src/fastmcp/server/auth/cimd.py index caef56f96..714de99e8 100644 --- a/src/fastmcp/server/auth/cimd.py +++ b/src/fastmcp/server/auth/cimd.py @@ -16,7 +16,6 @@ This module provides: from __future__ import annotations -import fnmatch import json import time from collections.abc import Mapping @@ -28,6 +27,7 @@ from urllib.parse import urlparse from pydantic import AnyHttpUrl, BaseModel, Field, field_validator +from fastmcp.server.auth.redirect_validation import matches_allowed_pattern from fastmcp.server.auth.ssrf import ( SSRFError, SSRFFetchError, @@ -422,6 +422,9 @@ class CIMDFetcher: def validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool: """Validate that a redirect_uri is allowed by the CIMD document. + Uses component-level matching (scheme, host, port, path) which correctly + handles RFC 8252 §7.3 loopback port flexibility and wildcard patterns. + Args: doc: The CIMD document redirect_uri: The redirect URI to validate @@ -438,14 +441,9 @@ class CIMDFetcher: for allowed in doc.redirect_uris: allowed_str = allowed.rstrip("/") - if redirect_uri == allowed_str: + if matches_allowed_pattern(redirect_uri, allowed_str): return True - # Check for wildcard port matching (http://localhost:*/callback) - if "*" in allowed_str: - if fnmatch.fnmatch(redirect_uri, allowed_str): - return True - return False diff --git a/src/fastmcp/server/auth/jwt_issuer.py b/src/fastmcp/server/auth/jwt_issuer.py index 90d7bc0cf..4e17eac60 100644 --- a/src/fastmcp/server/auth/jwt_issuer.py +++ b/src/fastmcp/server/auth/jwt_issuer.py @@ -17,11 +17,13 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +import fastmcp from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) -KDF_ITERATIONS = 1000000 +KDF_ITERATIONS = 1_000_000 +KDF_ITERATIONS_TEST = 10 @overload @@ -57,11 +59,14 @@ def derive_jwt_key( return base64.urlsafe_b64encode(derived_key) if low_entropy_material is not None: + iterations = ( + KDF_ITERATIONS_TEST if fastmcp.settings.test_mode else KDF_ITERATIONS + ) pbkdf2 = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=salt.encode(), - iterations=KDF_ITERATIONS, + iterations=iterations, ).derive(key_material=low_entropy_material.encode()) return base64.urlsafe_b64encode(pbkdf2) @@ -202,13 +207,19 @@ class JWTIssuer: return token - def verify_token(self, token: str) -> dict[str, Any]: + def verify_token( + self, + token: str, + expected_token_use: str = "access", + ) -> dict[str, Any]: """Verify and decode a FastMCP token. - Validates JWT signature, expiration, issuer, and audience. + Validates JWT signature, expiration, issuer, audience, and token type. Args: token: JWT token to verify + expected_token_use: Expected token type ("access" or "refresh"). + Defaults to "access", which rejects refresh tokens. Returns: Decoded token payload @@ -220,6 +231,19 @@ class JWTIssuer: # Decode and verify signature payload = self._jwt.decode(token, self._signing_key) + # Validate token type + token_use = payload.get("token_use", "access") + if token_use != expected_token_use: + logger.debug( + "Token type mismatch: expected %s, got %s", + expected_token_use, + token_use, + ) + raise JoseError( + f"Token type mismatch: expected {expected_token_use}, " + f"got {token_use}" + ) + # Validate expiration exp = payload.get("exp") if exp and exp < time.time(): diff --git a/src/fastmcp/server/auth/oauth_proxy/consent.py b/src/fastmcp/server/auth/oauth_proxy/consent.py index 0d7ccc32f..03b1e6ef3 100644 --- a/src/fastmcp/server/auth/oauth_proxy/consent.py +++ b/src/fastmcp/server/auth/oauth_proxy/consent.py @@ -62,13 +62,23 @@ class ConsentMixin: return f"__Host-{base_name}" return f"__{base_name}" + def _cookie_signing_key(self: OAuthProxy) -> bytes: + """Return the key used for HMAC-signing consent cookies. + + Uses the upstream client secret when available, falling back to the + JWT signing key (which is always present — OAuthProxy requires it + when no client secret is provided). + """ + if self._upstream_client_secret is not None: + return self._upstream_client_secret.get_secret_value().encode() + return self._jwt_signing_key + def _sign_cookie(self: OAuthProxy, payload: str) -> str: """Sign a cookie payload with HMAC-SHA256. Returns: base64(payload).base64(signature) """ - # Use upstream client secret as signing key - key = self._upstream_client_secret.get_secret_value().encode() + key = self._cookie_signing_key() signature = hmac.new(key, payload.encode(), hashlib.sha256).digest() signature_b64 = base64.b64encode(signature).decode() return f"{payload}.{signature_b64}" @@ -84,7 +94,7 @@ class ConsentMixin: payload, signature_b64 = signed_value.rsplit(".", 1) # Verify signature - key = self._upstream_client_secret.get_secret_value().encode() + key = self._cookie_signing_key() expected_sig = hmac.new(key, payload.encode(), hashlib.sha256).digest() provided_sig = base64.b64decode(signature_b64.encode()) @@ -100,9 +110,13 @@ class ConsentMixin: self: OAuthProxy, request: Request, base_name: str ) -> list[str]: """Decode and verify a signed base64-encoded JSON list from cookie. Returns [] if missing/invalid.""" - # Prefer secure name, but also check non-secure variant for dev secure_name = self._cookie_name(base_name) - raw = request.cookies.get(secure_name) or request.cookies.get(f"__{base_name}") + raw = request.cookies.get(secure_name) + # Only fall back to the non-__Host- name over plain HTTP. On HTTPS, + # __Host- enforces host-only scope; accepting the weaker name would + # let a sibling-subdomain attacker inject a domain-scoped cookie. + if not raw and not self._is_https: + raw = request.cookies.get(f"__{base_name}") if not raw: return [] try: @@ -271,8 +285,9 @@ class ConsentMixin: query_params["code_challenge_method"] = "S256" # Forward resource indicator if present in transaction - if resource := transaction.get("resource"): - query_params["resource"] = resource + if self._forward_resource: + if resource := transaction.get("resource"): + query_params["resource"] = resource # Extra configured parameters if self._extra_authorize_params: @@ -387,11 +402,13 @@ class ConsentMixin: cimd_domain=cimd_domain, ) response = create_secure_html_response(html) - # Store CSRF in cookie with short lifetime + # Merge new CSRF token with any existing ones (supports concurrent flows) + existing_tokens = self._decode_list_cookie(request, "MCP_CONSENT_STATE") + existing_tokens.append(csrf_token) self._set_list_cookie( response, "MCP_CONSENT_STATE", - self._encode_list_cookie([csrf_token]), + self._encode_list_cookie(existing_tokens), max_age=15 * 60, ) return response @@ -425,6 +442,23 @@ class ConsentMixin: "

Error

Invalid or expired consent token

", status_code=400 ) + # Double-submit CSRF check: verify the form token matches the cookie. + # Without this, an attacker who knows their own tx_id/csrf_token can + # CSRF the victim's browser into approving consent, bypassing the + # consent binding cookie protection. + cookie_csrf_tokens = self._decode_list_cookie(request, "MCP_CONSENT_STATE") + if csrf_token not in cookie_csrf_tokens: + logger.warning( + "CSRF double-submit check failed for transaction %s " + "(possible cross-site consent forgery)", + txn_id, + ) + return create_secure_html_response( + "

Error

Authorization session mismatch. " + "Please try authenticating again.

", + status_code=403, + ) + client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) if action == "approve": diff --git a/src/fastmcp/server/auth/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py index 0a72ae9f9..bcad01941 100644 --- a/src/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy/proxy.py @@ -22,9 +22,10 @@ import hashlib import secrets import time from base64 import urlsafe_b64encode -from typing import Any +from typing import Any, Literal from urllib.parse import urlencode, urlparse, urlunparse +import anyio import httpx from authlib.common.security import generate_token from authlib.integrations.httpx_client import AsyncOAuth2Client @@ -86,6 +87,7 @@ from fastmcp.server.auth.oauth_proxy.models import ( _hash_token, ) from fastmcp.server.auth.oauth_proxy.ui import create_error_html +from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -232,7 +234,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): upstream_authorization_endpoint: str, upstream_token_endpoint: str, upstream_client_id: str, - upstream_client_secret: str, + upstream_client_secret: str | None = None, upstream_revocation_endpoint: str | None = None, # Token validation token_verifier: TokenVerifier, @@ -246,6 +248,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin): valid_scopes: list[str] | None = None, # PKCE configuration forward_pkce: bool = True, + # Resource indicator (RFC 8707) + forward_resource: bool = True, # Token endpoint authentication token_endpoint_auth_method: str | None = None, # Extra parameters to forward to authorization endpoint @@ -257,7 +261,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # JWT signing key jwt_signing_key: str | bytes | None = None, # Consent screen configuration - require_authorization_consent: bool = True, + require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, # Token expiry fallback fallback_access_token_expiry_seconds: int | None = None, @@ -270,7 +274,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): upstream_authorization_endpoint: URL of upstream authorization endpoint upstream_token_endpoint: URL of upstream token endpoint upstream_client_id: Client ID registered with upstream server - upstream_client_secret: Client secret for upstream server + upstream_client_secret: Client secret for upstream server. Optional for + PKCE public clients or when using alternative credentials (e.g., + managed identity). When omitted, jwt_signing_key must be provided. upstream_revocation_endpoint: Optional upstream revocation endpoint token_verifier: Token verifier for validating access tokens base_url: Public URL of the server that exposes this FastMCP server; redirect path is @@ -305,7 +311,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to the upstream IdP. When False, authorization proceeds directly without user confirmation. - SECURITY WARNING: Only disable for local development or testing environments. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. consent_csp_policy: Content Security Policy for the consent page. If None (default), uses the built-in CSP policy with appropriate directives. If empty string "", disables CSP entirely (no meta tag is rendered). @@ -346,8 +354,10 @@ class OAuthProxy(OAuthProvider, ConsentMixin): self._upstream_authorization_endpoint: str = upstream_authorization_endpoint self._upstream_token_endpoint: str = upstream_token_endpoint self._upstream_client_id: str = upstream_client_id - self._upstream_client_secret: SecretStr = SecretStr( - secret_value=upstream_client_secret + self._upstream_client_secret: SecretStr | None = ( + SecretStr(secret_value=upstream_client_secret) + if upstream_client_secret is not None + else None ) self._upstream_revocation_endpoint: str | None = upstream_revocation_endpoint self._default_scope_str: str = " ".join(self.required_scopes or []) @@ -374,14 +384,22 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # PKCE configuration self._forward_pkce: bool = forward_pkce + # Resource indicator (RFC 8707) + self._forward_resource: bool = forward_resource # Token endpoint authentication self._token_endpoint_auth_method: str | None = token_endpoint_auth_method # Consent screen configuration - self._require_authorization_consent: bool = require_authorization_consent + self._require_authorization_consent: bool | Literal["external"] = ( + require_authorization_consent + ) self._consent_csp_policy: str | None = consent_csp_policy - if not require_authorization_consent: + if require_authorization_consent == "external": + logger.info( + "Built-in consent screen disabled; consent is handled externally." + ) + elif not require_authorization_consent: logger.warning( "Authorization consent screen disabled - only use for local development or testing. " + "In production, this screen protects against confused deputy attacks." @@ -397,6 +415,11 @@ class OAuthProxy(OAuthProvider, ConsentMixin): ) if jwt_signing_key is None: + if upstream_client_secret is None: + raise ValueError( + "jwt_signing_key is required when upstream_client_secret is not provided. " + "The JWT signing key cannot be derived without a client secret." + ) jwt_signing_key = derive_jwt_key( high_entropy_material=upstream_client_secret, salt="fastmcp-jwt-signing-key", @@ -528,6 +551,13 @@ class OAuthProxy(OAuthProvider, ConsentMixin): allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, ) + # Advisory locks for transparent upstream token refresh, keyed by + # upstream_token_id. Prevents concurrent async tasks from racing to + # refresh the same token within a single process. Does not protect + # against cross-process races in distributed deployments — those are + # handled by re-reading from storage after refresh failure. + self._refresh_locks: dict[str, anyio.Lock] = {} + logger.debug( "Initialized OAuth proxy provider with upstream server %s", self._upstream_authorization_endpoint, @@ -574,6 +604,29 @@ class OAuthProxy(OAuthProvider, ConsentMixin): ) return self._jwt_issuer + # ------------------------------------------------------------------------- + # Upstream OAuth Client + # ------------------------------------------------------------------------- + + def _create_upstream_oauth_client(self) -> AsyncOAuth2Client: + """Create an OAuth2 client for communicating with the upstream IdP. + + This is the single point for constructing the client used in token + exchange, refresh, and other upstream interactions. Subclasses can + override this to provide alternative authentication methods (e.g., + managed-identity client assertions instead of a static client secret). + """ + return AsyncOAuth2Client( + client_id=self._upstream_client_id, + client_secret=( + self._upstream_client_secret.get_secret_value() + if self._upstream_client_secret is not None + else None + ), + token_endpoint_auth_method=self._token_endpoint_auth_method, + timeout=HTTP_TIMEOUT_SECONDS, + ) + # ------------------------------------------------------------------------- # PKCE Helper Methods # ------------------------------------------------------------------------- @@ -745,7 +798,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): self._resource_url, ) raise AuthorizeError( - error="invalid_target", # type: ignore[arg-type] + error="invalid_target", # type: ignore[arg-type] # ty:ignore[invalid-argument-type] error_description="Resource does not match this server", ) @@ -765,7 +818,7 @@ 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 + error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type # ty:ignore[invalid-argument-type] error_description="Client ID is required", ) transaction = OAuthTransaction( @@ -786,8 +839,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin): ttl=15 * 60, # Auto-expire after 15 minutes ) - # If consent is disabled, skip consent screen and go directly to upstream IdP - if not self._require_authorization_consent: + # If consent is disabled or handled externally, skip consent screen + if self._require_authorization_consent is not True: upstream_url = self._build_upstream_authorize_url( txn_id, transaction.model_dump() ) @@ -848,7 +901,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 + error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type # ty:ignore[invalid-argument-type] error_description="Client ID is required", ) return AuthorizationCode( @@ -890,6 +943,16 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Get stored upstream tokens idp_tokens = code_model.idp_tokens + # Use IdP-granted scopes when available (RFC 6749 §5.1: the IdP MUST + # include a scope parameter when the granted scope differs from the + # requested scope). Fall back to requested scopes only when the IdP + # omits scope, meaning it granted exactly what was requested. + granted_scopes: list[str] = ( + parse_scopes(idp_tokens["scope"]) or [] + if "scope" in idp_tokens + else list(authorization_code.scopes) + ) + # Clean up client code (one-time use) await self._code_store.delete(key=authorization_code.code) @@ -931,7 +994,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): refresh_expires_in = None refresh_token_expires_at = None if idp_tokens.get("refresh_token"): - if "refresh_expires_in" in idp_tokens: + if "refresh_expires_in" in idp_tokens and int( + idp_tokens["refresh_expires_in"] + ): refresh_expires_in = int(idp_tokens["refresh_expires_in"]) refresh_token_expires_at = time.time() + refresh_expires_in logger.debug( @@ -956,7 +1021,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): refresh_token_expires_at=refresh_token_expires_at, expires_at=time.time() + expires_in, token_type=idp_tokens.get("token_type", "Bearer"), - scope=" ".join(authorization_code.scopes), + scope=" ".join(granted_scopes), client_id=client.client_id or "", created_at=time.time(), raw_token_data=idp_tokens, @@ -978,7 +1043,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): raise TokenError("invalid_client", "Client ID is required") fastmcp_access_token = self.jwt_issuer.issue_access_token( client_id=client.client_id, - scopes=authorization_code.scopes, + scopes=granted_scopes, jti=access_jti, expires_in=expires_in, upstream_claims=upstream_claims, @@ -990,7 +1055,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): if refresh_jti and refresh_expires_in: fastmcp_refresh_token = self.jwt_issuer.issue_refresh_token( client_id=client.client_id, - scopes=authorization_code.scopes, + scopes=granted_scopes, jti=refresh_jti, expires_in=refresh_expires_in, upstream_claims=upstream_claims, @@ -1023,7 +1088,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): key=_hash_token(fastmcp_refresh_token), value=RefreshTokenMetadata( client_id=client.client_id, - scopes=authorization_code.scopes, + scopes=granted_scopes, expires_at=int(time.time()) + refresh_expires_in, created_at=time.time(), ), @@ -1043,7 +1108,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): token_type="Bearer", expires_in=expires_in, refresh_token=fastmcp_refresh_token, - scope=" ".join(authorization_code.scopes), + scope=" ".join(granted_scopes), ) # ------------------------------------------------------------------------- @@ -1155,7 +1220,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): """ # Verify FastMCP refresh token try: - refresh_payload = self.jwt_issuer.verify_token(refresh_token.token) + refresh_payload = self.jwt_issuer.verify_token( + refresh_token.token, expected_token_use="refresh" + ) refresh_jti = refresh_payload["jti"] except Exception as e: logger.debug("FastMCP refresh token validation failed: %s", e) @@ -1182,12 +1249,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): raise TokenError("invalid_grant", "Refresh not supported for this token") # Refresh upstream token using authlib - oauth_client = AsyncOAuth2Client( - client_id=self._upstream_client_id, - client_secret=self._upstream_client_secret.get_secret_value(), - token_endpoint_auth_method=self._token_endpoint_auth_method, - timeout=HTTP_TIMEOUT_SECONDS, - ) + oauth_client = self._create_upstream_oauth_client() # Allow child classes to transform scopes before sending to upstream # This enables provider-specific scope formatting (e.g., Azure prefixing) @@ -1230,6 +1292,14 @@ class OAuthProxy(OAuthProvider, ConsentMixin): upstream_token_set.access_token = token_response["access_token"] upstream_token_set.expires_at = time.time() + new_expires_in + # Prefer IdP-granted scopes from refresh response (RFC 6749 §5.1) + refreshed_scopes: list[str] = ( + parse_scopes(token_response["scope"]) or [] + if "scope" in token_response + else scopes + ) + upstream_token_set.scope = " ".join(refreshed_scopes) + # Handle upstream refresh token rotation and expiry new_refresh_expires_in = None if new_upstream_refresh := token_response.get("refresh_token"): @@ -1238,7 +1308,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): logger.debug("Upstream refresh token rotated") # Update refresh token expiry if provided - if "refresh_expires_in" in token_response: + if "refresh_expires_in" in token_response and int( + token_response["refresh_expires_in"] + ): new_refresh_expires_in = int(token_response["refresh_expires_in"]) upstream_token_set.refresh_token_expires_at = ( time.time() + new_refresh_expires_in @@ -1288,7 +1360,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): new_access_jti = secrets.token_urlsafe(32) new_fastmcp_access = self.jwt_issuer.issue_access_token( client_id=client.client_id, - scopes=scopes, + scopes=refreshed_scopes, jti=new_access_jti, expires_in=new_expires_in, upstream_claims=upstream_claims, @@ -1310,7 +1382,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): new_refresh_jti = secrets.token_urlsafe(32) new_fastmcp_refresh = self.jwt_issuer.issue_refresh_token( client_id=client.client_id, - scopes=scopes, + scopes=refreshed_scopes, jti=new_refresh_jti, expires_in=new_refresh_expires_in or 60 * 60 * 24 * 30, # Fallback to 30 days @@ -1340,7 +1412,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): key=_hash_token(new_fastmcp_refresh), value=RefreshTokenMetadata( client_id=client.client_id, - scopes=scopes, + scopes=refreshed_scopes, expires_at=int(time.time()) + refresh_ttl, created_at=time.time(), ), @@ -1363,7 +1435,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): token_type="Bearer", expires_in=new_expires_in, refresh_token=new_fastmcp_refresh, # NEW refresh token (rotated) - scope=" ".join(scopes), + scope=" ".join(refreshed_scopes), ) # ------------------------------------------------------------------------- @@ -1381,7 +1453,107 @@ class OAuthProxy(OAuthProvider, ConsentMixin): """ return upstream_token_set.access_token - async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override] + def _uses_alternate_verification(self) -> bool: + """Whether this provider verifies a different token than the access token. + + When True, ``load_access_token`` patches the validated result with + the upstream access token, scopes, and expiry so that the returned + ``AccessToken`` reflects the access token rather than the + verification token. + + The default implementation compares token values, but subclasses + should override this to use an intent-based flag so the patch is + applied even when the verification token and access token happen to + carry the same value (e.g., some OIDC providers issue identical + JWTs for both). + """ + return False + + async def _try_transparent_refresh( + self, + upstream_token_set: UpstreamTokenSet, + ) -> UpstreamTokenSet: + """Refresh the upstream token transparently and update storage. + + Called during load_access_token when the upstream token has expired + but a refresh token is available. This avoids returning a 401 that + would force the client into a full re-authentication flow. + + Mutates and returns the upstream_token_set with refreshed token data. + Raises on failure (caller should catch and fall through to None). + """ + scopes = upstream_token_set.scope.split() if upstream_token_set.scope else [] + upstream_scopes = self._prepare_scopes_for_upstream_refresh(scopes) + oauth_client = self._create_upstream_oauth_client() + + token_response: dict[str, Any] = await oauth_client.refresh_token( + url=self._upstream_token_endpoint, + refresh_token=upstream_token_set.refresh_token, + scope=" ".join(upstream_scopes) if upstream_scopes else None, + **self._extra_token_params, + ) + logger.debug( + "Transparent upstream refresh succeeded (token_id=%s)", + upstream_token_set.upstream_token_id[:8], + ) + + # Calculate new expiry + if "expires_in" in token_response: + new_expires_in = int(token_response["expires_in"]) + elif self._fallback_access_token_expiry_seconds is not None: + new_expires_in = self._fallback_access_token_expiry_seconds + else: + new_expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS + + upstream_token_set.access_token = token_response["access_token"] + upstream_token_set.expires_at = time.time() + new_expires_in + upstream_token_set.scope = " ".join( + parse_scopes(token_response["scope"]) or [] + if "scope" in token_response + else scopes + ) + + # Handle upstream refresh token rotation + new_refresh_expires_in = None + if new_upstream_refresh := token_response.get("refresh_token"): + if new_upstream_refresh != upstream_token_set.refresh_token: + upstream_token_set.refresh_token = new_upstream_refresh + if "refresh_expires_in" in token_response and int( + token_response["refresh_expires_in"] + ): + new_refresh_expires_in = int(token_response["refresh_expires_in"]) + upstream_token_set.refresh_token_expires_at = ( + time.time() + new_refresh_expires_in + ) + elif upstream_token_set.refresh_token_expires_at: + new_refresh_expires_in = int( + upstream_token_set.refresh_token_expires_at - time.time() + ) + else: + new_refresh_expires_in = 60 * 60 * 24 * 30 + upstream_token_set.refresh_token_expires_at = ( + time.time() + new_refresh_expires_in + ) + + upstream_token_set.raw_token_data = { + **upstream_token_set.raw_token_data, + **token_response, + } + + refresh_ttl = new_refresh_expires_in or ( + int(upstream_token_set.refresh_token_expires_at - time.time()) + if upstream_token_set.refresh_token_expires_at + else 60 * 60 * 24 * 30 + ) + await self._upstream_token_store.put( + key=upstream_token_set.upstream_token_id, + value=upstream_token_set, + ttl=max(refresh_ttl, new_expires_in, 1), + ) + + return upstream_token_set + + async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override] # ty:ignore[invalid-method-override] """Validate FastMCP JWT by swapping for upstream token. This implements the token swap pattern: @@ -1389,7 +1561,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin): 2. Look up upstream token via JTI mapping 3. Decrypt upstream token 4. Validate upstream token with provider (GitHub API, JWT validation, etc.) - 5. Return upstream validation result + 5. If upstream validation fails, attempt transparent refresh + 6. Return upstream validation result The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. @@ -1425,15 +1598,91 @@ class OAuthProxy(OAuthProvider, ConsentMixin): return None validated = await self._token_validator.verify_token(verification_token) + # 4. If upstream validation failed due to token expiry and we + # have a refresh token, attempt transparent refresh to avoid + # forcing the client into a full re-auth flow. Only refresh on + # expiry — other failures (scope mismatch, revocation) won't be + # helped by a refresh and would just burn tokens. + if ( + not validated + and upstream_token_set.refresh_token + and upstream_token_set.expires_at <= time.time() + ): + try: + token_id = upstream_token_set.upstream_token_id + + # Advisory lock prevents concurrent requests from racing + # to refresh the same upstream token. + if token_id not in self._refresh_locks: + self._refresh_locks[token_id] = anyio.Lock() + lock = self._refresh_locks[token_id] + + async with lock: + # Re-read from storage — another task may have + # already refreshed while we waited for the lock. + upstream_token_set = ( + await self._upstream_token_store.get(key=token_id) + or upstream_token_set + ) + + verification_token = self._get_verification_token( + upstream_token_set + ) + if verification_token is not None: + validated = await self._token_validator.verify_token( + verification_token + ) + + # Only refresh if the (possibly reloaded) token is + # still expired — a non-expiry failure on a fresh + # token (scope mismatch, revocation) won't be + # helped by refreshing. + if ( + not validated + and upstream_token_set.expires_at <= time.time() + ): + upstream_token_set = await self._try_transparent_refresh( + upstream_token_set + ) + verification_token = self._get_verification_token( + upstream_token_set + ) + if verification_token is not None: + validated = await self._token_validator.verify_token( + verification_token + ) + except Exception as e: + logger.debug("Transparent upstream refresh failed: %s", e) + # In a distributed deployment, another worker may have + # already refreshed and rotated the token, causing our + # stale refresh token to fail. Re-read and re-validate. + try: + reloaded = await self._upstream_token_store.get( + key=upstream_token_set.upstream_token_id + ) + if reloaded: + verification_token = self._get_verification_token(reloaded) + if verification_token is not None: + validated = await self._token_validator.verify_token( + verification_token + ) + if validated: + upstream_token_set = reloaded + except Exception: + pass + if not validated: logger.debug("Upstream token validation failed") return None - # When the verification token differs from the access token - # (e.g., id_token verification), ensure the returned AccessToken + # When alternate verification is in use (e.g., id_token + # verification in OIDCProxy), ensure the returned AccessToken # carries the upstream access token and its scopes, not the - # verification token's values. - if verification_token != upstream_token_set.access_token: + # verification token's values. We use an intent-based check + # rather than value equality because some IdPs issue identical + # JWTs for both access_token and id_token, which would cause + # the scope patch to be skipped even though it's needed. + if self._uses_alternate_verification(): validated = validated.model_copy( update={ "token": upstream_token_set.access_token, @@ -1474,13 +1723,26 @@ class OAuthProxy(OAuthProvider, ConsentMixin): async with httpx.AsyncClient( timeout=HTTP_TIMEOUT_SECONDS ) as http_client: + revocation_data: dict[str, str] = {"token": token.token} + request_kwargs: dict[str, Any] = {"data": revocation_data} + + # Use the factory method when available (supports alternative auth like + # client assertions for managed identity), falling back to basic auth + # or client_id-only for public clients per RFC 7009 + oauth_client = self._create_upstream_oauth_client() + if oauth_client.client_secret is not None: + # Client secret is available, use HTTP Basic auth + request_kwargs["auth"] = ( + self._upstream_client_id, + oauth_client.client_secret, + ) + else: + # No secret; public client must still identify itself per RFC 7009 + revocation_data["client_id"] = self._upstream_client_id + await http_client.post( self._upstream_revocation_endpoint, - data={"token": token.token}, - auth=( - self._upstream_client_id, - self._upstream_client_secret.get_secret_value(), - ), + **request_kwargs, ) logger.debug("Successfully revoked token with upstream server") except Exception as e: @@ -1674,7 +1936,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # When consent is enabled, the browser that approved consent receives # a signed cookie. A different browser (e.g., a victim lured to the # IdP URL) won't have this cookie and will be rejected. - if self._require_authorization_consent: + if self._require_authorization_consent is True: consent_token = transaction_model.consent_token if not consent_token: logger.error("Transaction %s missing consent_token", txn_id) @@ -1705,12 +1967,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): transaction = transaction_model.model_dump() # Exchange IdP code for tokens (server-side) - oauth_client = AsyncOAuth2Client( - client_id=self._upstream_client_id, - client_secret=self._upstream_client_secret.get_secret_value(), - token_endpoint_auth_method=self._token_endpoint_auth_method, - timeout=HTTP_TIMEOUT_SECONDS, - ) + oauth_client = self._create_upstream_oauth_client() try: idp_redirect_uri = ( diff --git a/src/fastmcp/server/auth/oauth_proxy/ui.py b/src/fastmcp/server/auth/oauth_proxy/ui.py index a8a12efe3..4ae6b5fb5 100644 --- a/src/fastmcp/server/auth/oauth_proxy/ui.py +++ b/src/fastmcp/server/auth/oauth_proxy/ui.py @@ -84,7 +84,7 @@ def create_consent_html( detail_rows = [ ("Application Name", html_module.escape(client_name or client_id)), ("Application Website", html_module.escape(client_website_url or "N/A")), - ("Application ID", client_id), + ("Application ID", html_module.escape(client_id)), ("Redirect URI", redirect_uri_escaped), ( "Requested Scopes", diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 418bea34c..ebf048c5a 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -10,6 +10,7 @@ This implementation is based on: """ from collections.abc import Sequence +from typing import Literal import httpx from key_value.aio.protocols import AsyncKeyValue @@ -203,7 +204,7 @@ class OIDCProxy(OAuthProxy): strict: bool | None = None, # Upstream server configuration client_id: str, - client_secret: str, + client_secret: str | None = None, audience: str | None = None, timeout_seconds: int | None = None, # Token verifier @@ -223,8 +224,9 @@ class OIDCProxy(OAuthProxy): # Token validation configuration token_endpoint_auth_method: str | None = None, # Consent screen configuration - require_authorization_consent: bool = True, + require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, # Extra parameters extra_authorize_params: dict[str, str] | None = None, extra_token_params: dict[str, str] | None = None, @@ -239,7 +241,9 @@ class OIDCProxy(OAuthProxy): config_url: URL of upstream configuration strict: Optional strict flag for the configuration client_id: Client ID registered with upstream server - client_secret: Client secret for upstream server + client_secret: Client secret for upstream server. Optional for PKCE public + clients or when using alternative credentials. When omitted, + jwt_signing_key must be provided. audience: Audience for upstream server timeout_seconds: HTTP request timeout in seconds token_verifier: Optional custom token verifier (e.g., IntrospectionTokenVerifier for opaque tokens). @@ -271,7 +275,9 @@ class OIDCProxy(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to the upstream IdP. When False, authorization proceeds directly without user confirmation. - SECURITY WARNING: Only disable for local development or testing environments. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. consent_csp_policy: Content Security Policy for the consent page. If None (default), uses the built-in CSP policy with appropriate directives. If empty string "", disables CSP entirely (no meta tag is rendered). @@ -295,8 +301,12 @@ class OIDCProxy(OAuthProxy): if not client_id: raise ValueError("Missing required client id") - if not client_secret: - raise ValueError("Missing required client secret") + if not client_secret and not jwt_signing_key: + raise ValueError( + "Either client_secret or jwt_signing_key must be provided. " + "jwt_signing_key is required when client_secret is omitted " + "(e.g., for PKCE public clients)." + ) if not base_url: raise ValueError("Missing required base URL") @@ -368,6 +378,7 @@ class OIDCProxy(OAuthProxy): "token_endpoint_auth_method": token_endpoint_auth_method, "require_authorization_consent": require_authorization_consent, "consent_csp_policy": consent_csp_policy, + "forward_resource": forward_resource, "fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds, "enable_cimd": enable_cimd, } @@ -429,6 +440,15 @@ class OIDCProxy(OAuthProxy): return id_token return upstream_token_set.access_token + def _uses_alternate_verification(self) -> bool: + """Return True when id_token verification is enabled. + + This ensures ``load_access_token`` always patches the validated + result with upstream scopes, even when the IdP issues the same + JWT for both ``access_token`` and ``id_token``. + """ + return self._verify_id_token + def get_oidc_configuration( self, config_url: AnyHttpUrl, diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 3eec61776..5b1017c6a 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -21,6 +21,8 @@ Example: ``` """ +from typing import Literal + from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl @@ -69,8 +71,9 @@ class Auth0Provider(OIDCProxy): allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool = True, + require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, ) -> None: """Initialize Auth0 OAuth provider. @@ -95,7 +98,9 @@ class Auth0Provider(OIDCProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Auth0. When False, authorization proceeds directly without user confirmation. - SECURITY WARNING: Only disable for local development or testing environments. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. """ # Parse scopes if provided as string auth0_required_scopes = ( @@ -116,6 +121,7 @@ class Auth0Provider(OIDCProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 1e4f913b6..fc5ca0666 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -23,10 +23,11 @@ Example: from __future__ import annotations +from typing import Literal + from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl -from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oidc_proxy import OIDCProxy from fastmcp.server.auth.providers.jwt import JWTVerifier @@ -108,8 +109,9 @@ class AWSCognitoProvider(OIDCProxy): allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool = True, + require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, ): """Initialize AWS Cognito OAuth provider. @@ -134,7 +136,9 @@ class AWSCognitoProvider(OIDCProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to AWS Cognito. When False, authorization proceeds directly without user confirmation. - SECURITY WARNING: Only disable for local development or testing environments. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. """ # Parse scopes if provided as string required_scopes_final = ( @@ -147,6 +151,7 @@ class AWSCognitoProvider(OIDCProxy): # Store Cognito-specific info for claim filtering self.user_pool_id = user_pool_id self.aws_region = aws_region + self.client_id = client_id # Initialize OIDC proxy with Cognito discovery super().__init__( @@ -163,6 +168,7 @@ class AWSCognitoProvider(OIDCProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, ) logger.debug( @@ -178,7 +184,7 @@ class AWSCognitoProvider(OIDCProxy): audience: str | None = None, required_scopes: list[str] | None = None, timeout_seconds: int | None = None, - ) -> TokenVerifier: + ) -> AWSCognitoTokenVerifier: """Creates a Cognito-specific token verifier with claim filtering. Args: @@ -189,7 +195,7 @@ class AWSCognitoProvider(OIDCProxy): """ return AWSCognitoTokenVerifier( issuer=str(self.oidc_config.issuer), - audience=audience, + audience=audience or self.client_id, algorithm=algorithm, jwks_uri=str(self.oidc_config.jwks_uri), required_scopes=required_scopes, diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 009cf9316..cd1de2c4a 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -8,12 +8,13 @@ from __future__ import annotations import hashlib from collections import OrderedDict -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast import httpx from key_value.aio.protocols import AsyncKeyValue from fastmcp.dependencies import Dependency +from fastmcp.server.auth.auth import MultiAuth from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes @@ -24,6 +25,8 @@ if TYPE_CHECKING: from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull + from fastmcp.server.auth.auth import AuthProvider + logger = get_logger(__name__) # Standard OIDC scopes that should never be prefixed with identifier_uri. @@ -96,7 +99,7 @@ class AzureProvider(OAuthProxy): self, *, client_id: str, - client_secret: str, + client_secret: str | None = None, tenant_id: str, required_scopes: list[str], base_url: str, @@ -107,16 +110,21 @@ class AzureProvider(OAuthProxy): allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool = True, + require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, base_authority: str = "login.microsoftonline.com", http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, ) -> None: """Initialize Azure OAuth provider. Args: client_id: Azure application (client) ID from your App registration - client_secret: Azure client secret from your App registration + client_secret: Azure client secret from your App registration. Optional when + using alternative credentials (e.g., managed identity with a custom + _create_upstream_oauth_client override). When omitted, jwt_signing_key + must be provided. tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers") identifier_uri: Optional Application ID URI for your custom API (defaults to api://{client_id}). This URI is automatically prefixed to all required_scopes during initialization. @@ -154,10 +162,14 @@ class AzureProvider(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Azure. When False, authorization proceeds directly without user confirmation. - SECURITY WARNING: Only disable for local development or testing environments. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches. When provided, the client is reused for JWT key fetches and the caller is responsible for its lifecycle. When None (default), a fresh client is created per fetch. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. """ # Parse scopes if provided as string parsed_required_scopes = parse_scopes(required_scopes) @@ -193,15 +205,17 @@ class AzureProvider(OAuthProxy): # NOT standard OIDC scopes (openid, profile, email, offline_access). # Filter out OIDC scopes from validation - they'll still be sent to Azure # during authorization (handled by _prefix_scopes_for_azure). - if parsed_required_scopes: - validation_scopes = [ - s for s in parsed_required_scopes if s not in OIDC_SCOPES - ] - # If all scopes were OIDC scopes, use None (no scope validation) - if not validation_scopes: - validation_scopes = None - else: - validation_scopes = None + validation_scopes = [ + s for s in (parsed_required_scopes or []) if s not in OIDC_SCOPES + ] + if not validation_scopes: + raise ValueError( + "AzureProvider requires at least one non-OIDC scope in " + "required_scopes (e.g., 'read', 'write'). OIDC scopes like " + "'openid', 'profile', 'email', and 'offline_access' are not " + "included in Azure access token claims and cannot be used for " + "scope enforcement." + ) token_verifier = JWTVerifier( jwks_uri=jwks_uri, @@ -235,7 +249,9 @@ class AzureProvider(OAuthProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, valid_scopes=parsed_required_scopes, + enable_cimd=enable_cimd, ) authority_info = "" @@ -500,13 +516,23 @@ class AzureProvider(OAuthProxy): self._obo_credentials.move_to_end(key) return self._obo_credentials[key] - credential = OnBehalfOfCredential( - tenant_id=self._tenant_id, - client_id=self._upstream_client_id, - client_secret=self._upstream_client_secret.get_secret_value(), - user_assertion=user_assertion, - authority=f"https://{self._base_authority}", - ) + obo_kwargs: dict[str, Any] = { + "tenant_id": self._tenant_id, + "client_id": self._upstream_client_id, + "user_assertion": user_assertion, + "authority": f"https://{self._base_authority}", + } + if self._upstream_client_secret is not None: + obo_kwargs["client_secret"] = ( + self._upstream_client_secret.get_secret_value() + ) + else: + raise ValueError( + "OBO token exchange requires either a client_secret or a subclass " + "that overrides get_obo_credential() to provide alternative credentials " + "(e.g., client_assertion_func for managed identity)." + ) + credential = OnBehalfOfCredential(**obo_kwargs) self._obo_credentials[key] = credential # Evict oldest if over capacity @@ -642,6 +668,17 @@ def _require_azure_identity(feature: str) -> None: ) from e +def _find_azure_provider(auth: AuthProvider | None) -> AzureProvider | None: + """Extract an AzureProvider from an auth provider, unwrapping MultiAuth if needed.""" + if isinstance(auth, AzureProvider): + return auth + + if isinstance(auth, MultiAuth) and isinstance(auth.server, AzureProvider): + return auth.server + + return None + + class _EntraOBOToken(Dependency[str]): """Dependency that performs OBO token exchange for Microsoft Entra. @@ -666,13 +703,14 @@ class _EntraOBOToken(Dependency[str]): ) server = get_server() - if not isinstance(server.auth, AzureProvider): + azure_provider = _find_azure_provider(server.auth) + if azure_provider is None: raise RuntimeError( "EntraOBOToken requires an AzureProvider as the auth provider. " f"Current provider: {type(server.auth).__name__}" ) - credential = await server.auth.get_obo_credential( + credential = await azure_provider.get_obo_credential( user_assertion=access_token.token, ) diff --git a/src/fastmcp/server/auth/providers/clerk.py b/src/fastmcp/server/auth/providers/clerk.py new file mode 100644 index 000000000..409a065b3 --- /dev/null +++ b/src/fastmcp/server/auth/providers/clerk.py @@ -0,0 +1,384 @@ +"""Clerk OAuth provider for FastMCP. + +This module provides a complete Clerk OAuth integration that's ready to use +with a Clerk domain, client ID, and client secret. It handles all the complexity +of Clerk's OAuth/OIDC flow, token validation, and user management. + +Clerk uses standard OIDC endpoints derived from the instance domain +(e.g., ``https://.clerk.accounts.dev``). Token verification is +performed via the introspection endpoint (RFC 7662) for security-critical +checks (active status, audience, scopes), followed by the userinfo endpoint +for profile enrichment. Userinfo failure is non-fatal. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.clerk import ClerkProvider + + auth = ClerkProvider( + domain="saving-primate-16.clerk.accounts.dev", + client_id="your-clerk-client-id", + client_secret="your-clerk-client-secret", + base_url="https://my-server.com", + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` +""" + +from __future__ import annotations + +import contextlib +from typing import Literal + +import httpx +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import TokenVerifier +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class ClerkTokenVerifier(TokenVerifier): + """Token verifier for Clerk OAuth tokens. + + Clerk issues standard OIDC tokens. Verification uses the introspection + endpoint (RFC 7662) as the primary security gate — it confirms the token + is active and provides metadata (scopes, expiry, audience). The userinfo + endpoint is called second for profile enrichment (name, email, picture) + and its failure is non-fatal. + + When a ``client_id`` is configured, the audience from introspection is + validated against it. When ``required_scopes`` are configured, + introspection must return the token's scopes — the verifier will not + assume scopes when introspection is unavailable. + """ + + def __init__( + self, + *, + domain: str, + client_id: str | None = None, + client_secret: str | None = None, + required_scopes: list[str] | None = None, + timeout_seconds: int = 10, + http_client: httpx.AsyncClient | None = None, + ): + """Initialize the Clerk token verifier. + + Args: + domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev") + client_id: Clerk OAuth client ID, used for introspection endpoint authentication + client_secret: Clerk OAuth client secret, used for introspection endpoint authentication + required_scopes: Required OAuth scopes (e.g., ["openid", "email", "profile"]) + timeout_seconds: HTTP request timeout + http_client: Optional httpx.AsyncClient for connection pooling. When provided, + the client is reused across calls and the caller is responsible for its + lifecycle. When None (default), a fresh client is created per call. + """ + super().__init__(required_scopes=required_scopes) + self.domain = domain.rstrip("/") + self._client_id = client_id + self._client_secret = client_secret + self.timeout_seconds = timeout_seconds + self._http_client = http_client + + self._userinfo_url = f"https://{self.domain}/oauth/userinfo" + self._introspection_url = f"https://{self.domain}/oauth/token_info" + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify a Clerk OAuth token via introspection and userinfo. + + Calls the introspection endpoint first to validate the token and + retrieve auth metadata (active status, scopes, expiry, audience). + If the token passes security checks, the userinfo endpoint is called + for profile enrichment. Userinfo failure is non-fatal. + + When a ``client_id`` is configured, the token's audience must match it. + When ``required_scopes`` are configured, introspection must confirm + them; tokens are rejected if scope information is unavailable. + """ + try: + async with ( + contextlib.nullcontext(self._http_client) + if self._http_client is not None + else httpx.AsyncClient(timeout=self.timeout_seconds) + ) as client: + # Step 1: Validate token via introspection (RFC 7662). + # Security-critical checks (active, audience, scopes) come first. + introspect_data_payload: dict = {"token": token} + introspect_kwargs: dict = { + "data": introspect_data_payload, + "headers": {"User-Agent": "FastMCP-Clerk-OAuth"}, + } + + if self._client_id and self._client_secret: + introspect_kwargs["auth"] = ( + self._client_id, + self._client_secret, + ) + elif self._client_id: + introspect_data_payload["client_id"] = self._client_id + + introspect_response = await client.post( + self._introspection_url, + **introspect_kwargs, + ) + + if introspect_response.status_code != 200: + logger.debug( + "Clerk introspection failed: %d", + introspect_response.status_code, + ) + return None + + introspect_data = introspect_response.json() + + # RFC 7662 requires the 'active' field in the response. + # A missing field indicates a malformed response — reject. + if "active" not in introspect_data or not introspect_data["active"]: + logger.debug( + "Clerk introspection: token inactive or missing 'active' field" + ) + return None + + scope_str = introspect_data.get("scope", "") + token_scopes = scope_str.split() if scope_str else [] + + aud = introspect_data.get("aud") or introspect_data.get("client_id") + + expires_at: int | None = None + exp = introspect_data.get("exp") + if exp is not None: + with contextlib.suppress(ValueError, TypeError): + expires_at = int(exp) + + if self._client_id and aud != self._client_id: + logger.debug( + "Clerk token audience mismatch: got %s, expected %s", + aud, + self._client_id, + ) + return None + + if self.required_scopes: + if not token_scopes: + logger.debug( + "Clerk token missing scope information; " + "cannot verify required scopes %s", + self.required_scopes, + ) + return None + token_scopes_set = set(token_scopes) + required_scopes_set = set(self.required_scopes) + if not required_scopes_set.issubset(token_scopes_set): + logger.debug( + "Clerk token missing required scopes. Has %s, needs %s", + token_scopes_set, + required_scopes_set, + ) + return None + + # Step 2: Fetch user profile via userinfo. + # Enriches the token with profile data (name, email, picture). + sub = introspect_data.get("sub") + user_data: dict = {} + try: + userinfo_response = await client.get( + self._userinfo_url, + headers={ + "Authorization": f"Bearer {token}", + "User-Agent": "FastMCP-Clerk-OAuth", + }, + ) + if userinfo_response.status_code == 200: + user_data = userinfo_response.json() + if not sub: + sub = user_data.get("sub") + except Exception as e: + logger.debug("Clerk userinfo call failed: %s", e) + + if not sub: + logger.debug("Clerk token missing 'sub' claim") + return None + + access_token = AccessToken( + token=token, + client_id=aud or sub, + scopes=token_scopes, + expires_at=expires_at, + claims={ + "sub": sub, + "aud": aud, + "email": user_data.get("email"), + "email_verified": user_data.get("email_verified"), + "name": user_data.get("name"), + "picture": user_data.get("picture"), + "given_name": user_data.get("given_name"), + "family_name": user_data.get("family_name"), + "preferred_username": user_data.get("preferred_username"), + "iss": user_data.get("iss"), + "clerk_user_data": user_data or None, + }, + ) + logger.debug("Clerk token verified successfully for sub=%s", sub) + return access_token + + except httpx.RequestError as e: + logger.debug("Failed to verify Clerk token: %s", e) + return None + except Exception as e: + logger.debug("Clerk token verification error: %s", e) + return None + + +class ClerkProvider(OAuthProxy): + """Complete Clerk OAuth provider for FastMCP. + + This provider makes it trivial to add Clerk OAuth protection to any + FastMCP server. Provide your Clerk instance domain, OAuth app credentials, + and a base URL, and you're ready to go. + + Clerk uses standard OIDC endpoints derived from the instance domain. + All endpoint URLs are constructed automatically from the domain parameter. + + Features: + - Transparent OAuth proxy to Clerk + - Automatic token validation via Clerk's userinfo & introspection APIs + - User information extraction from Clerk's OIDC claims + - PKCE support (S256) + - Minimal configuration required + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.clerk import ClerkProvider + + auth = ClerkProvider( + domain="saving-primate-16.clerk.accounts.dev", + client_id="your-clerk-client-id", + client_secret="your-clerk-client-secret", + base_url="https://my-server.com", + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + domain: str, + client_id: str, + client_secret: str | None = None, + base_url: AnyHttpUrl | str, + issuer_url: AnyHttpUrl | str | None = None, + redirect_path: str | None = None, + required_scopes: list[str] | None = None, + valid_scopes: list[str] | None = None, + timeout_seconds: int = 10, + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | None = None, + require_authorization_consent: bool | Literal["external"] = True, + consent_csp_policy: str | None = None, + forward_resource: bool = True, + extra_authorize_params: dict[str, str] | None = None, + http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, + ): + """Initialize Clerk OAuth provider. + + Args: + domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev"). + This is used to derive all OAuth/OIDC endpoint URLs. + client_id: Clerk OAuth application client ID + client_secret: Clerk OAuth application client secret. + Optional for PKCE public clients. When omitted, jwt_signing_key must be provided. + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + 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. + redirect_path: Redirect path configured in Clerk OAuth app (defaults to "/auth/callback") + required_scopes: Required Clerk scopes (defaults to ["openid", "email", "profile"]). + Clerk supports: "openid", "email", "profile", "public_metadata", + "private_metadata", "offline_access". + valid_scopes: All scopes that clients are allowed to request, advertised through + well-known endpoints. Defaults to required_scopes if not provided. + timeout_seconds: HTTP request timeout for Clerk API calls (defaults to 10) + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + 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``). + jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes + are provided, they will be used as is. If a string is provided, it will be derived + into a 32-byte key. If not provided, the upstream client secret will be used to + derive a 32-byte key using PBKDF2. + require_authorization_consent: Whether to require user consent before authorizing + clients (default True). When "external", the built-in consent screen is skipped + but no warning is logged, indicating that consent is handled externally by Clerk. + consent_csp_policy: Custom CSP policy for the consent page. + extra_authorize_params: Additional parameters to forward to Clerk's authorization + endpoint. Example: {"prompt": "login"} to force re-authentication. + http_client: Optional httpx.AsyncClient for connection pooling in token verification. + When provided, the client is reused across verify_token calls and the caller + is responsible for its lifecycle. When None (default), a fresh client is created + per call. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. + """ + domain = domain.rstrip("/") + + required_scopes_final = ( + parse_scopes(required_scopes) + if required_scopes is not None + else ["openid", "email", "profile"] + ) + + parsed_valid_scopes = ( + parse_scopes(valid_scopes) if valid_scopes is not None else None + ) + + token_verifier = ClerkTokenVerifier( + domain=domain, + client_id=client_id, + client_secret=client_secret, + required_scopes=required_scopes_final, + timeout_seconds=timeout_seconds, + http_client=http_client, + ) + + extra_authorize_params_final = ( + dict(extra_authorize_params) if extra_authorize_params else {} + ) + + super().__init__( + upstream_authorization_endpoint=f"https://{domain}/oauth/authorize", + upstream_token_endpoint=f"https://{domain}/oauth/token", + upstream_client_id=client_id, + upstream_client_secret=client_secret, + token_verifier=token_verifier, + base_url=base_url, + redirect_path=redirect_path, + issuer_url=issuer_url or base_url, + allowed_client_redirect_uris=allowed_client_redirect_uris, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + extra_authorize_params=extra_authorize_params_final or None, + valid_scopes=parsed_valid_scopes, + enable_cimd=enable_cimd, + ) + + logger.debug( + "Initialized Clerk OAuth provider for domain %s with scopes: %s", + domain, + required_scopes_final, + ) diff --git a/src/fastmcp/server/auth/providers/descope.py b/src/fastmcp/server/auth/providers/descope.py index f0f99f399..3bdccf8d5 100644 --- a/src/fastmcp/server/auth/providers/descope.py +++ b/src/fastmcp/server/auth/providers/descope.py @@ -68,6 +68,9 @@ class DescopeProvider(RemoteAuthProvider): project_id: str | None = None, descope_base_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, token_verifier: TokenVerifier | None = None, ): """Initialize Descope metadata provider. @@ -80,6 +83,11 @@ class DescopeProvider(RemoteAuthProvider): descope_base_url: Your Descope base URL (e.g., "https://api.descope.com"). Used with project_id for backwards compatibility. required_scopes: Optional list of scopes that must be present in validated tokens. These scopes will be included in the protected resource metadata. + scopes_supported: Optional list of scopes to advertise in OAuth metadata. + If None, uses required_scopes. Use this when the scopes clients should + request differ from the scopes enforced on tokens. + resource_name: Optional name for the protected resource metadata. + resource_documentation: Optional documentation URL for the protected resource. token_verifier: Optional token verifier. If None, creates JWT verifier for Descope """ self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) @@ -149,6 +157,9 @@ class DescopeProvider(RemoteAuthProvider): token_verifier=token_verifier, authorization_servers=[AnyHttpUrl(issuer_url)], base_url=self.base_url, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, ) def get_routes( diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py index 7ef187202..d646743f9 100644 --- a/src/fastmcp/server/auth/providers/discord.py +++ b/src/fastmcp/server/auth/providers/discord.py @@ -24,6 +24,7 @@ from __future__ import annotations import contextlib import time from datetime import datetime +from typing import Literal import httpx from key_value.aio.protocols import AsyncKeyValue @@ -48,6 +49,7 @@ class DiscordTokenVerifier(TokenVerifier): def __init__( self, *, + expected_client_id: str, required_scopes: list[str] | None = None, timeout_seconds: int = 10, http_client: httpx.AsyncClient | None = None, @@ -55,6 +57,7 @@ class DiscordTokenVerifier(TokenVerifier): """Initialize the Discord token verifier. Args: + expected_client_id: Expected Discord OAuth client ID for audience binding required_scopes: Required OAuth scopes (e.g., ['email']) timeout_seconds: HTTP request timeout http_client: Optional httpx.AsyncClient for connection pooling. When provided, @@ -62,6 +65,7 @@ class DiscordTokenVerifier(TokenVerifier): lifecycle. When None (default), a fresh client is created per call. """ super().__init__(required_scopes=required_scopes) + self.expected_client_id = expected_client_id self.timeout_seconds = timeout_seconds self._http_client = http_client @@ -121,6 +125,13 @@ class DiscordTokenVerifier(TokenVerifier): user_data = token_info.get("user", {}) application = token_info.get("application") or {} client_id = str(application.get("id", "unknown")) + if client_id != self.expected_client_id: + logger.debug( + "Discord token app ID mismatch: expected %s, got %s", + self.expected_client_id, + client_id, + ) + return None # Create AccessToken with Discord user info access_token = AccessToken( @@ -192,9 +203,11 @@ class DiscordProvider(OAuthProxy): allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool = True, + require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, ): """Initialize Discord OAuth provider. @@ -221,10 +234,14 @@ class DiscordProvider(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Discord. When False, authorization proceeds directly without user confirmation. - SECURITY WARNING: Only disable for local development or testing environments. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. http_client: Optional httpx.AsyncClient for connection pooling in token verification. When provided, the client is reused across verify_token calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. """ # Parse scopes if provided as string required_scopes_final = ( @@ -235,6 +252,7 @@ class DiscordProvider(OAuthProxy): # Create Discord token verifier token_verifier = DiscordTokenVerifier( + expected_client_id=client_id, required_scopes=required_scopes_final, timeout_seconds=timeout_seconds, http_client=http_client, @@ -255,6 +273,8 @@ class DiscordProvider(OAuthProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + enable_cimd=enable_cimd, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 3d0c31d27..b8f5a16e2 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -22,6 +22,7 @@ Example: from __future__ import annotations import contextlib +from typing import Literal import httpx from key_value.aio.protocols import AsyncKeyValue @@ -32,6 +33,7 @@ from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.token_cache import TokenCache logger = get_logger(__name__) @@ -41,6 +43,10 @@ class GitHubTokenVerifier(TokenVerifier): GitHub OAuth tokens are opaque (not JWTs), so we verify them by calling GitHub's API to check if they're valid and get user info. + + Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive + integer to cache successful verification results and avoid repeated + GitHub API calls for the same token. """ def __init__( @@ -48,6 +54,8 @@ class GitHubTokenVerifier(TokenVerifier): *, required_scopes: list[str] | None = None, timeout_seconds: int = 10, + cache_ttl_seconds: int | None = None, + max_cache_size: int | None = None, http_client: httpx.AsyncClient | None = None, ): """Initialize the GitHub token verifier. @@ -55,6 +63,10 @@ class GitHubTokenVerifier(TokenVerifier): Args: required_scopes: Required OAuth scopes (e.g., ['user:email']) timeout_seconds: HTTP request timeout + cache_ttl_seconds: How long to cache verification results in seconds. + Caching is disabled by default (None). Set to a positive integer + to enable (e.g., 300 for 5 minutes). + max_cache_size: Maximum number of tokens to cache. Default: 10 000. http_client: Optional httpx.AsyncClient for connection pooling. When provided, the client is reused across calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. @@ -62,9 +74,18 @@ class GitHubTokenVerifier(TokenVerifier): super().__init__(required_scopes=required_scopes) self.timeout_seconds = timeout_seconds self._http_client = http_client + self._cache = TokenCache( + ttl_seconds=cache_ttl_seconds, + max_size=max_cache_size, + ) async def verify_token(self, token: str) -> AccessToken | None: """Verify GitHub OAuth token by calling GitHub API.""" + is_cached, cached_result = self._cache.get(token) + if is_cached: + logger.debug("GitHub token cache hit") + return cached_result + try: async with ( contextlib.nullcontext(self._http_client) @@ -103,6 +124,7 @@ class GitHubTokenVerifier(TokenVerifier): ) # Extract scopes from X-OAuth-Scopes header if available + scopes_verified = scopes_response.status_code == 200 oauth_scopes_header = scopes_response.headers.get("x-oauth-scopes", "") token_scopes = [ scope.strip() @@ -127,7 +149,7 @@ class GitHubTokenVerifier(TokenVerifier): return None # Create AccessToken with GitHub user info - return AccessToken( + result = AccessToken( token=token, client_id=str(user_data.get("id", "unknown")), # Use GitHub user ID scopes=token_scopes, @@ -141,6 +163,9 @@ class GitHubTokenVerifier(TokenVerifier): "github_user_data": user_data, }, ) + if scopes_verified: + self._cache.set(token, result) + return result except httpx.RequestError as e: logger.debug("Failed to verify GitHub token: %s", e) @@ -188,12 +213,16 @@ class GitHubProvider(OAuthProxy): redirect_path: str | None = None, required_scopes: list[str] | None = None, timeout_seconds: int = 10, + cache_ttl_seconds: int | None = None, + max_cache_size: int | None = None, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool = True, + require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, ): """Initialize GitHub OAuth provider. @@ -206,6 +235,10 @@ class GitHubProvider(OAuthProxy): redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback") required_scopes: Required GitHub scopes (defaults to ["user"]) timeout_seconds: HTTP request timeout for GitHub API calls (defaults to 10) + cache_ttl_seconds: How long to cache token verification results in seconds. + Caching is disabled by default (None). Set to a positive integer to + enable (e.g., 300 for 5 minutes). + max_cache_size: Maximum number of tokens to cache. Default: 10 000. allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). @@ -217,10 +250,14 @@ class GitHubProvider(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to GitHub. When False, authorization proceeds directly without user confirmation. - SECURITY WARNING: Only disable for local development or testing environments. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. http_client: Optional httpx.AsyncClient for connection pooling in token verification. When provided, the client is reused across verify_token calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. """ # Parse scopes if provided as string required_scopes_final = ( @@ -231,6 +268,8 @@ class GitHubProvider(OAuthProxy): token_verifier = GitHubTokenVerifier( required_scopes=required_scopes_final, timeout_seconds=timeout_seconds, + cache_ttl_seconds=cache_ttl_seconds, + max_cache_size=max_cache_size, http_client=http_client, ) @@ -249,6 +288,8 @@ class GitHubProvider(OAuthProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + enable_cimd=enable_cimd, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 265a9dd48..48b84b9c0 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -23,6 +23,7 @@ from __future__ import annotations import contextlib import time +from typing import Literal import httpx from key_value.aio.protocols import AsyncKeyValue @@ -37,11 +38,30 @@ from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) +GOOGLE_SCOPE_ALIASES: dict[str, str] = { + "email": "https://www.googleapis.com/auth/userinfo.email", + "profile": "https://www.googleapis.com/auth/userinfo.profile", +} + + +def _normalize_google_scope(scope: str) -> str: + """Normalize a Google scope shorthand to its canonical full URI. + + Google accepts shorthand scopes like "email" and "profile" in authorization + requests, but returns the full URI form in token responses. This normalizes + to the full URI so comparisons work regardless of which form was used. + """ + return GOOGLE_SCOPE_ALIASES.get(scope, scope) + + class GoogleTokenVerifier(TokenVerifier): """Token verifier for Google OAuth tokens. - Google OAuth tokens are opaque (not JWTs), so we verify them - by calling Google's tokeninfo API to check if they're valid and get user info. + Google OAuth tokens are opaque (not JWTs), so we verify them by calling + Google's tokeninfo endpoint with the access token as a query parameter. + This returns the OAuth app ID (``aud``), granted scopes, and expiry time. + User profile data (name, picture, etc.) is fetched separately from the + v2 userinfo endpoint when the token is valid. """ def __init__( @@ -60,21 +80,33 @@ class GoogleTokenVerifier(TokenVerifier): the client is reused across calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. """ - super().__init__(required_scopes=required_scopes) + normalized = ( + [_normalize_google_scope(s) for s in required_scopes] + if required_scopes + else required_scopes + ) + super().__init__(required_scopes=normalized) self.timeout_seconds = timeout_seconds self._http_client = http_client async def verify_token(self, token: str) -> AccessToken | None: - """Verify Google OAuth token by calling Google's tokeninfo API.""" + """Verify a Google OAuth token using the tokeninfo endpoint. + + Calls ``https://oauth2.googleapis.com/tokeninfo?access_token=TOKEN`` + to validate the token and retrieve the OAuth app ID (``aud``), granted + scopes, and expiry time. On success, fetches user profile data from + the v2 userinfo endpoint to populate name, picture, and locale claims. + """ try: async with ( contextlib.nullcontext(self._http_client) if self._http_client is not None else httpx.AsyncClient(timeout=self.timeout_seconds) ) as client: - # Use Google's tokeninfo endpoint to validate the token + # Step 1: Verify token via tokeninfo endpoint. + # Returns aud (OAuth app ID), scope (space-separated), expires_in, sub, email. response = await client.get( - "https://www.googleapis.com/oauth2/v1/tokeninfo", + "https://oauth2.googleapis.com/tokeninfo", params={"access_token": token}, headers={"User-Agent": "FastMCP-Google-OAuth"}, ) @@ -86,19 +118,23 @@ class GoogleTokenVerifier(TokenVerifier): ) return None - token_info = response.json() + token_data = response.json() - # Check if token is expired - expires_in = token_info.get("expires_in") - if expires_in and int(expires_in) <= 0: - logger.debug("Google token has expired") + # aud is the OAuth app ID (client_id / audience) + aud = token_data.get("aud") + if not aud: + logger.debug("Google tokeninfo missing 'aud' claim") return None - # Extract scopes from token info - scope_string = token_info.get("scope", "") - token_scopes = [ - scope.strip() for scope in scope_string.split(" ") if scope.strip() - ] + # sub is required (unique Google user ID) + sub = token_data.get("sub") + if not sub: + logger.debug("Google tokeninfo missing 'sub' claim") + return None + + # Parse scopes directly from the tokeninfo response (space-separated) + scope_str = token_data.get("scope", "") + token_scopes = scope_str.split() if scope_str else [] # Check required scopes if self.required_scopes: @@ -112,46 +148,46 @@ class GoogleTokenVerifier(TokenVerifier): ) return None - # Get additional user info if we have the right scopes - user_data = {} - if "openid" in token_scopes or "profile" in token_scopes: - try: - userinfo_response = await client.get( - "https://www.googleapis.com/oauth2/v2/userinfo", - headers={ - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-Google-OAuth", - }, - ) - if userinfo_response.status_code == 200: - user_data = userinfo_response.json() - except Exception as e: - logger.debug("Failed to fetch Google user info: %s", e) + # Compute expiry from expires_in (seconds until expiry) + expires_at: int | None = None + expires_in = token_data.get("expires_in") + if expires_in is not None: + with contextlib.suppress(ValueError, TypeError): + expires_at = int(time.time()) + int(expires_in) - # Calculate expiration time - expires_at = None - if expires_in: - expires_at = int(time.time() + int(expires_in)) + # Step 2: Fetch user profile from v2 userinfo endpoint. + # tokeninfo provides auth data; userinfo provides name, picture, locale. + user_data: dict = {} + try: + userinfo_response = await client.get( + "https://www.googleapis.com/oauth2/v2/userinfo", + headers={ + "Authorization": f"Bearer {token}", + "User-Agent": "FastMCP-Google-OAuth", + }, + ) + if userinfo_response.status_code == 200: + user_data = userinfo_response.json() + except Exception as e: + logger.debug("Failed to fetch Google user profile: %s", e) - # Create AccessToken with Google user info access_token = AccessToken( token=token, - client_id=token_info.get( - "audience", "unknown" - ), # Use audience as client_id + client_id=aud, scopes=token_scopes, expires_at=expires_at, claims={ - "sub": user_data.get("id") - or token_info.get("user_id", "unknown"), - "email": user_data.get("email"), + "sub": sub, + "aud": aud, + "email": token_data.get("email") or user_data.get("email"), + "email_verified": token_data.get("email_verified") + or user_data.get("verified_email"), "name": user_data.get("name"), "picture": user_data.get("picture"), "given_name": user_data.get("given_name"), "family_name": user_data.get("family_name"), "locale": user_data.get("locale"), - "google_user_data": user_data, - "google_token_info": token_info, + "google_user_data": user_data or None, }, ) logger.debug("Google token verified successfully") @@ -197,25 +233,30 @@ class GoogleProvider(OAuthProxy): self, *, client_id: str, - client_secret: str, + client_secret: str | None = None, base_url: AnyHttpUrl | str, issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, required_scopes: list[str] | None = None, + valid_scopes: list[str] | None = None, timeout_seconds: int = 10, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool = True, + require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, extra_authorize_params: dict[str, str] | None = None, http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, ): """Initialize Google OAuth provider. Args: client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com") - client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123...") + client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123..."). + Optional for PKCE public clients (e.g., native apps). When omitted, + jwt_signing_key must be provided. base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) 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. @@ -224,6 +265,12 @@ class GoogleProvider(OAuthProxy): - "openid" for OpenID Connect (default) - "https://www.googleapis.com/auth/userinfo.email" for email access - "https://www.googleapis.com/auth/userinfo.profile" for profile info + Google scope shorthands like "email" and "profile" are automatically + normalized to their full URI forms for token verification. + valid_scopes: All scopes that clients are allowed to request, advertised through + well-known endpoints. Defaults to required_scopes if not provided. Use this + when you want clients to be able to request additional scopes beyond the + required minimum. Shorthands are normalized to full URI forms. timeout_seconds: HTTP request timeout for Google API calls (defaults to 10) allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. @@ -236,7 +283,9 @@ class GoogleProvider(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Google. When False, authorization proceeds directly without user confirmation. - SECURITY WARNING: Only disable for local development or testing environments. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by Google's own consent). + SECURITY WARNING: Only set to False for local development or testing environments. extra_authorize_params: Additional parameters to forward to Google's authorization endpoint. By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure refresh tokens are returned. You can override these defaults or add additional parameters. @@ -244,6 +293,8 @@ class GoogleProvider(OAuthProxy): http_client: Optional httpx.AsyncClient for connection pooling in token verification. When provided, the client is reused across verify_token calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. """ # Parse scopes if provided as string # Google requires at least one scope - openid is the minimal OIDC scope @@ -251,7 +302,19 @@ class GoogleProvider(OAuthProxy): parse_scopes(required_scopes) if required_scopes is not None else ["openid"] ) + # Normalize valid_scopes if provided + parsed_valid_scopes = ( + parse_scopes(valid_scopes) if valid_scopes is not None else None + ) + valid_scopes_final = ( + [_normalize_google_scope(s) for s in parsed_valid_scopes] + if parsed_valid_scopes is not None + else None + ) + # Create Google token verifier + # Normalization of shorthand scopes (e.g. "email" -> full URI) happens + # inside GoogleTokenVerifier so required_scopes match what Google returns. token_verifier = GoogleTokenVerifier( required_scopes=required_scopes_final, timeout_seconds=timeout_seconds, @@ -285,7 +348,10 @@ class GoogleProvider(OAuthProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, extra_authorize_params=extra_authorize_params_final, + valid_scopes=valid_scopes_final, + enable_cimd=enable_cimd, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/in_memory.py b/src/fastmcp/server/auth/providers/in_memory.py index de1e786b1..08a7fc2a1 100644 --- a/src/fastmcp/server/auth/providers/in_memory.py +++ b/src/fastmcp/server/auth/providers/in_memory.py @@ -284,7 +284,7 @@ class InMemoryOAuthProvider(OAuthProvider): scope=" ".join(scopes), ) - async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override] + async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override] # ty:ignore[invalid-method-override] token_obj = self.access_tokens.get(token) if token_obj: if token_obj.expires_at is not None and token_obj.expires_at < time.time(): @@ -295,7 +295,7 @@ class InMemoryOAuthProvider(OAuthProvider): return token_obj return None - async def verify_token(self, token: str) -> AccessToken | None: # type: ignore[override] + async def verify_token(self, token: str) -> AccessToken | None: # type: ignore[override] # ty:ignore[invalid-method-override] """ Verify a bearer token and return access info if valid. diff --git a/src/fastmcp/server/auth/providers/introspection.py b/src/fastmcp/server/auth/providers/introspection.py index 09e20abfd..65d9d86ce 100644 --- a/src/fastmcp/server/auth/providers/introspection.py +++ b/src/fastmcp/server/auth/providers/introspection.py @@ -25,9 +25,7 @@ from __future__ import annotations import base64 import contextlib -import hashlib import time -from dataclasses import dataclass from typing import Any, Literal, get_args import httpx @@ -36,18 +34,11 @@ from pydantic import AnyHttpUrl, SecretStr from fastmcp.server.auth import AccessToken, TokenVerifier from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.token_cache import TokenCache logger = get_logger(__name__) -@dataclass -class _IntrospectionCacheEntry: - """Cached introspection result with expiration.""" - - result: AccessToken - expires_at: float - - ClientAuthMethod = Literal["client_secret_basic", "client_secret_post"] @@ -86,9 +77,6 @@ class IntrospectionTokenVerifier(TokenVerifier): ``` """ - # Default cache settings - DEFAULT_MAX_CACHE_SIZE = 10000 - def __init__( self, *, @@ -154,96 +142,9 @@ class IntrospectionTokenVerifier(TokenVerifier): self._http_client = http_client self.logger = get_logger(__name__) - # Cache configuration (None or 0 = disabled) - self._cache_ttl = cache_ttl_seconds or 0 - self._max_cache_size = ( - max_cache_size - if max_cache_size is not None - else self.DEFAULT_MAX_CACHE_SIZE - ) - self._cache: dict[str, _IntrospectionCacheEntry] = {} - self._last_cleanup = time.monotonic() - self._cleanup_interval = 60 # Cleanup every 60 seconds - - def _hash_token(self, token: str) -> str: - """Hash token for use as cache key. - - Using SHA-256 for memory efficiency (fixed 64-char hex digest - regardless of token length). - """ - return hashlib.sha256(token.encode("utf-8")).hexdigest() - - def _cleanup_expired_cache(self) -> None: - """Remove expired entries from cache.""" - now = time.time() - expired = [key for key, entry in self._cache.items() if entry.expires_at < now] - for key in expired: - del self._cache[key] - if expired: - self.logger.debug("Cleaned up %d expired cache entries", len(expired)) - - def _maybe_cleanup(self) -> None: - """Periodically cleanup expired entries to prevent unbounded growth.""" - now = time.monotonic() - if now - self._last_cleanup > self._cleanup_interval: - self._cleanup_expired_cache() - self._last_cleanup = now - - def _get_cached(self, token: str) -> tuple[bool, AccessToken | None]: - """Get cached introspection result. - - Returns: - Tuple of (is_cached, result): - - (True, AccessToken) if cached valid token - - (False, None) if not in cache or expired - """ - if self._cache_ttl <= 0 or self._max_cache_size <= 0: - return (False, None) # Caching disabled - - cache_key = self._hash_token(token) - entry = self._cache.get(cache_key) - - if entry is None: - return (False, None) # Not in cache - - if entry.expires_at < time.time(): - del self._cache[cache_key] - return (False, None) # Expired - - # Return a copy to prevent mutations from affecting cached value - return (True, entry.result.model_copy(deep=True)) - - def _set_cached(self, token: str, result: AccessToken) -> None: - """Cache a valid introspection result with TTL. - - Only successful validations are cached. Failures (inactive, expired, - missing scopes, errors) are never cached to avoid sticky false negatives. - """ - if self._cache_ttl <= 0 or self._max_cache_size <= 0: - return # Caching disabled - - # Periodic cleanup - self._maybe_cleanup() - - # Check cache size limit - if len(self._cache) >= self._max_cache_size: - self._cleanup_expired_cache() - # If still at limit after cleanup, evict oldest entry - if len(self._cache) >= self._max_cache_size: - oldest_key = next(iter(self._cache)) - del self._cache[oldest_key] - - cache_key = self._hash_token(token) - - # Use token's expiration if available and sooner than TTL - expires_at = time.time() + self._cache_ttl - if result.expires_at: - expires_at = min(expires_at, float(result.expires_at)) - - # Store a deep copy to prevent mutations from affecting cached value - self._cache[cache_key] = _IntrospectionCacheEntry( - result=result.model_copy(deep=True), - expires_at=expires_at, + self._cache = TokenCache( + ttl_seconds=cache_ttl_seconds, + max_size=max_cache_size, ) def _create_basic_auth_header(self) -> str: @@ -293,7 +194,7 @@ class IntrospectionTokenVerifier(TokenVerifier): AccessToken object if valid and active, None if invalid, inactive, or expired """ # Check cache first - is_cached, cached_result = self._get_cached(token) + is_cached, cached_result = self._cache.get(token) if is_cached: self.logger.debug("Token introspection cache hit") return cached_result @@ -388,7 +289,7 @@ class IntrospectionTokenVerifier(TokenVerifier): expires_at=int(exp) if exp else None, claims=introspection_data, # Store full response for extensibility ) - self._set_cached(token, result) + self._cache.set(token, result) return result except httpx.TimeoutException: diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index 5640fa390..a97c1bd60 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -114,7 +114,7 @@ class RSAKeyPair: header["kid"] = kid # Create payload - payload = { + payload: dict[str, str | int | list[str]] = { "sub": subject, "iss": issuer, "iat": int(time.time()), @@ -139,6 +139,20 @@ class RSAKeyPair: return token_bytes.decode("utf-8") +def _looks_like_pem_public_key(key: str | bytes) -> bool: + """Return True when key text appears to be PEM-encoded asymmetric key material.""" + if isinstance(key, bytes): + key = key.decode("utf-8", errors="replace") + key_text = key.strip() + pem_markers = ( + "-----BEGIN PUBLIC KEY-----", + "-----BEGIN RSA PUBLIC KEY-----", + "-----BEGIN EC PUBLIC KEY-----", + "-----BEGIN CERTIFICATE-----", + ) + return any(marker in key_text for marker in pem_markers) + + class JWTVerifier(TokenVerifier): """ JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. @@ -161,7 +175,7 @@ class JWTVerifier(TokenVerifier): def __init__( self, *, - public_key: str | None = None, + public_key: str | bytes | None = None, jwks_uri: str | None = None, issuer: str | list[str] | None = None, audience: str | list[str] | None = None, @@ -225,6 +239,17 @@ class JWTVerifier(TokenVerifier): }: raise ValueError(f"Unsupported algorithm: {algorithm}.") + if algorithm.startswith("HS"): + if jwks_uri: + raise ValueError( + "Symmetric HS* algorithms cannot be used with jwks_uri; " + "configure a shared secret via public_key instead." + ) + if public_key and _looks_like_pem_public_key(public_key): + raise ValueError( + "Symmetric HS* algorithms require a shared secret, not a public key." + ) + # Parse scopes if provided as string parsed_required_scopes = ( parse_scopes(required_scopes) if required_scopes is not None else None @@ -251,7 +276,7 @@ class JWTVerifier(TokenVerifier): self._jwks_cache_time: float = 0 self._cache_ttl = 3600 # 1 hour - async def _get_verification_key(self, token: str) -> str: + async def _get_verification_key(self, token: str) -> str | bytes: """Get the verification key for the token.""" if self.public_key: return self.public_key diff --git a/src/fastmcp/server/auth/providers/oci.py b/src/fastmcp/server/auth/providers/oci.py index 29527f07a..98011e4ed 100644 --- a/src/fastmcp/server/auth/providers/oci.py +++ b/src/fastmcp/server/auth/providers/oci.py @@ -77,6 +77,8 @@ Example: ``` """ +from typing import Literal + from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl @@ -128,8 +130,9 @@ class OCIProvider(OIDCProxy): allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool = True, + require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, ) -> None: """Initialize OCI OIDC provider. @@ -163,6 +166,7 @@ class OCIProvider(OIDCProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/propelauth.py b/src/fastmcp/server/auth/providers/propelauth.py index 0183a4c31..82e55e172 100644 --- a/src/fastmcp/server/auth/providers/propelauth.py +++ b/src/fastmcp/server/auth/providers/propelauth.py @@ -80,6 +80,9 @@ class PropelAuthProvider(RemoteAuthProvider): introspection_client_secret: str | SecretStr, base_url: AnyHttpUrl | str, required_scopes: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, resource: AnyHttpUrl | str | None = None, token_introspection_overrides: ( PropelAuthTokenIntrospectionOverrides | None @@ -93,6 +96,11 @@ class PropelAuthProvider(RemoteAuthProvider): introspection_client_secret: Introspection Client Secret from the PropelAuth Dashboard base_url: Public URL of this FastMCP server required_scopes: Optional list of scopes that must be present in tokens + scopes_supported: Optional list of scopes to advertise in OAuth metadata. + If None, uses required_scopes. Use this when the scopes clients should + request differ from the scopes enforced on tokens. + resource_name: Optional name for the protected resource metadata. + resource_documentation: Optional documentation URL for the protected resource. resource: Optional resource URI (RFC 8707) identifying this MCP server. Use this when multiple MCP servers share the same PropelAuth authorization server (e.g. ``resource="https://api.example.com/mcp"``), @@ -125,6 +133,9 @@ class PropelAuthProvider(RemoteAuthProvider): token_verifier=token_verifier, authorization_servers=[authorization_server_url], base_url=base_url, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, ) def get_routes( diff --git a/src/fastmcp/server/auth/providers/scalekit.py b/src/fastmcp/server/auth/providers/scalekit.py index d55ec14fd..ffcacc9c5 100644 --- a/src/fastmcp/server/auth/providers/scalekit.py +++ b/src/fastmcp/server/auth/providers/scalekit.py @@ -69,6 +69,9 @@ class ScalekitProvider(RemoteAuthProvider): mcp_url: AnyHttpUrl | str | None = None, client_id: str | None = None, required_scopes: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, token_verifier: TokenVerifier | None = None, ): """Initialize Scalekit resource server provider. @@ -80,6 +83,11 @@ class ScalekitProvider(RemoteAuthProvider): mcp_url: Deprecated alias for base_url. Will be removed in a future release. client_id: Deprecated parameter, no longer required. Will be removed in a future release. required_scopes: Optional list of scopes that must be present in tokens + scopes_supported: Optional list of scopes to advertise in OAuth metadata. + If None, uses required_scopes. Use this when the scopes clients should + request differ from the scopes enforced on tokens. + resource_name: Optional name for the protected resource metadata. + resource_documentation: Optional documentation URL for the protected resource. token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit """ # Resolve base_url from mcp_url if needed (backwards compatibility) @@ -140,6 +148,9 @@ class ScalekitProvider(RemoteAuthProvider): AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}") ], base_url=base_url_value, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, ) def get_routes( diff --git a/src/fastmcp/server/auth/providers/supabase.py b/src/fastmcp/server/auth/providers/supabase.py index b6253e46c..527d701ee 100644 --- a/src/fastmcp/server/auth/providers/supabase.py +++ b/src/fastmcp/server/auth/providers/supabase.py @@ -34,7 +34,7 @@ class SupabaseProvider(RemoteAuthProvider): 1. Supabase Project Setup: - Create a Supabase project at https://supabase.com - Note your project URL (e.g., "https://abc123.supabase.co") - - Configure your JWT algorithm in Supabase Auth settings (HS256, RS256, or ES256) + - Configure your JWT algorithm in Supabase Auth settings (RS256 or ES256) - Asymmetric keys (RS256/ES256) are recommended for production 2. JWT Verification: @@ -74,8 +74,11 @@ class SupabaseProvider(RemoteAuthProvider): project_url: AnyHttpUrl | str, base_url: AnyHttpUrl | str, auth_route: str = "/auth/v1", - algorithm: Literal["HS256", "RS256", "ES256"] = "ES256", + algorithm: Literal["RS256", "ES256"] = "ES256", required_scopes: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, token_verifier: TokenVerifier | None = None, ): """Initialize Supabase metadata provider. @@ -85,11 +88,16 @@ class SupabaseProvider(RemoteAuthProvider): base_url: Public URL of this FastMCP server auth_route: Supabase Auth route. Defaults to "/auth/v1". Can be customized for self-hosted Supabase Auth setups using custom routes. - algorithm: JWT signing algorithm (HS256, RS256, or ES256). Must match your + algorithm: JWT signing algorithm (RS256 or ES256). Must match your Supabase Auth configuration. Defaults to ES256. required_scopes: Optional list of scopes to require for all requests. Note: Supabase currently uses RLS policies for authorization. OAuth-level scopes are an upcoming feature. + scopes_supported: Optional list of scopes to advertise in OAuth metadata. + If None, uses required_scopes. Use this when the scopes clients should + request differ from the scopes enforced on tokens. + resource_name: Optional name for the protected resource metadata. + resource_documentation: Optional documentation URL for the protected resource. token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase """ self.project_url = str(project_url).rstrip("/") @@ -121,6 +129,9 @@ class SupabaseProvider(RemoteAuthProvider): token_verifier=token_verifier, authorization_servers=[AnyHttpUrl(f"{self.project_url}/{self.auth_route}")], base_url=self.base_url, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, ) def get_routes( diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 3b81ca127..85dd8feca 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -11,6 +11,7 @@ Choose based on your WorkOS setup and authentication requirements. from __future__ import annotations import contextlib +from typing import Literal import httpx from key_value.aio.protocols import AsyncKeyValue @@ -83,12 +84,26 @@ class WorkOSTokenVerifier(TokenVerifier): return None user_data = response.json() + token_scopes = ( + parse_scopes(user_data.get("scope") or user_data.get("scopes")) + or [] + ) + + if self.required_scopes and not all( + scope in token_scopes for scope in self.required_scopes + ): + logger.debug( + "WorkOS token missing required scopes. required=%s actual=%s", + self.required_scopes, + token_scopes, + ) + return None # Create AccessToken with WorkOS user info return AccessToken( token=token, client_id=str(user_data.get("sub", "unknown")), - scopes=self.required_scopes or [], + scopes=token_scopes, expires_at=None, # Will be set from token introspection if needed claims={ "sub": user_data.get("sub"), @@ -156,9 +171,11 @@ class WorkOSProvider(OAuthProxy): allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool = True, + require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, ): """Initialize WorkOS OAuth provider. @@ -183,10 +200,14 @@ class WorkOSProvider(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to WorkOS. When False, authorization proceeds directly without user confirmation. - SECURITY WARNING: Only disable for local development or testing environments. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. http_client: Optional httpx.AsyncClient for connection pooling in token verification. When provided, the client is reused across verify_token calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. """ # Apply defaults and ensure authkit_domain is a full URL authkit_domain_str = authkit_domain @@ -220,6 +241,8 @@ class WorkOSProvider(OAuthProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + enable_cimd=enable_cimd, ) logger.debug( @@ -272,6 +295,9 @@ class AuthKitProvider(RemoteAuthProvider): base_url: AnyHttpUrl | str, client_id: str | None = None, required_scopes: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, token_verifier: TokenVerifier | None = None, ): """Initialize AuthKit metadata provider. @@ -283,6 +309,11 @@ class AuthKitProvider(RemoteAuthProvider): validate the JWT audience claim. Found in your WorkOS Dashboard under API Keys. This is the project-level client ID, not individual MCP client IDs. required_scopes: Optional list of scopes to require for all requests + scopes_supported: Optional list of scopes to advertise in OAuth metadata. + If None, uses required_scopes. Use this when the scopes clients should + request differ from the scopes enforced on tokens. + resource_name: Optional name for the protected resource metadata. + resource_documentation: Optional documentation URL for the protected resource. token_verifier: Optional token verifier. If None, creates JWT verifier for AuthKit """ self.authkit_domain = str(authkit_domain).rstrip("/") @@ -314,6 +345,9 @@ class AuthKitProvider(RemoteAuthProvider): token_verifier=token_verifier, authorization_servers=[AnyHttpUrl(self.authkit_domain)], base_url=self.base_url, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, ) def get_routes( diff --git a/src/fastmcp/server/auth/redirect_validation.py b/src/fastmcp/server/auth/redirect_validation.py index 4d011416f..7e55a477b 100644 --- a/src/fastmcp/server/auth/redirect_validation.py +++ b/src/fastmcp/server/auth/redirect_validation.py @@ -68,6 +68,17 @@ 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: + """Check if a host is a loopback address. + + Per RFC 8252 §7.3, loopback addresses include localhost, 127.0.0.1, and ::1. + """ + if not host: + return False + host = host.lower() + return host in ("localhost", "127.0.0.1", "::1") + + def _match_port( uri_port: str | None, pattern_port: str | None, @@ -164,9 +175,10 @@ def matches_allowed_pattern(uri: str, pattern: str) -> bool: if not _match_host(uri_host, pattern_host): return False - # Port must match (with * wildcard support) - if not _match_port(uri_port, pattern_port, uri_parsed.scheme.lower()): - 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 _match_port(uri_port, pattern_port, uri_parsed.scheme.lower()): + return False # Path must match (with fnmatch wildcards) return _match_path(uri_parsed.path, pattern_parsed.path) diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index d7b23a245..11bbbf90e 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -26,7 +26,7 @@ from starlette.requests import Request from typing_extensions import TypeVar from uncalled_for import SharedContext -from fastmcp.resources.resource import ResourceResult +from fastmcp.resources.base import ResourceResult from fastmcp.server.elicitation import ( AcceptedElicitation, CancelledElicitation, @@ -432,7 +432,7 @@ class Context: delta = current - last if delta > 0: await execution.progress.increment(delta) - execution._fastmcp_last_progress = current # type: ignore[attr-defined] + execution._fastmcp_last_progress = current # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] if message is not None: await execution.progress.set_message(message) @@ -587,7 +587,7 @@ class Context: Example:: - from fastmcp.server.apps import UI_EXTENSION_ID + from fastmcp.apps.config import UI_EXTENSION_ID @mcp.tool async def my_tool(ctx: Context) -> str: @@ -679,7 +679,7 @@ class Context: session_id = str(uuid4()) # Cache on session for consistency - session._fastmcp_state_prefix = session_id # type: ignore[attr-defined] + session._fastmcp_state_prefix = session_id # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] return session_id @property @@ -1181,7 +1181,7 @@ class Context: from fastmcp.server.tasks.elicitation import elicit_for_task return await elicit_for_task( - task_id=self._task_id, # type: ignore[arg-type] + task_id=self._task_id, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] session=self._session, message=message, schema=schema, @@ -1356,6 +1356,18 @@ class Context: await _reset_visibility(self) +_MCP_LEVEL_SEVERITY: dict[LoggingLevel, int] = { + "debug": 0, + "info": 1, + "notice": 2, + "warning": 3, + "error": 4, + "critical": 5, + "alert": 6, + "emergency": 7, +} + + async def _log_to_server_and_client( data: LogData, session: ServerSession, @@ -1364,6 +1376,13 @@ async def _log_to_server_and_client( related_request_id: str | None = None, ) -> None: """Log a message to the server and client.""" + from fastmcp.server.low_level import MiddlewareServerSession + + if isinstance(session, MiddlewareServerSession): + min_level = session._minimum_logging_level or session.fastmcp.client_log_level + if min_level is not None: + if _MCP_LEVEL_SEVERITY[level] < _MCP_LEVEL_SEVERITY[min_level]: + return msg_prefix = f"Sending {level.upper()} to client" diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 4536b165b..b024a1037 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -9,8 +9,10 @@ from __future__ import annotations import contextlib import inspect +import json import logging import weakref +from collections import OrderedDict from collections.abc import AsyncGenerator, Callable from contextlib import AsyncExitStack, asynccontextmanager from contextvars import ContextVar, Token @@ -35,7 +37,10 @@ from uncalled_for.resolution import _Depends from fastmcp.exceptions import FastMCPError from fastmcp.server.auth import AccessToken from fastmcp.server.http import _current_http_request -from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool +from fastmcp.utilities.async_utils import ( + call_sync_fn_in_threadpool, + is_coroutine_function, +) from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type _logger = logging.getLogger(__name__) @@ -69,6 +74,7 @@ __all__ = [ "get_task_context", "get_task_session", "is_docket_available", + "register_task_server", "register_task_session", "require_docket", "resolve_dependencies", @@ -171,11 +177,40 @@ def get_task_session(session_id: str) -> ServerSession | None: _current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar( "server", default=None ) + +# --- Background task server map --- +# Maps task_id → server weakref so background workers can resolve the correct +# server for mounted-child tasks. Follows the same pattern as _task_sessions. +# Populated in submit_to_docket() where the child server is in context; +# consulted in get_server() when running inside a Docket worker. + +_task_server_map: OrderedDict[str, weakref.ref[FastMCP]] = OrderedDict() +_TASK_SERVER_MAP_MAX_SIZE = 10_000 + + +def register_task_server(task_id: str, server: FastMCP) -> None: + """Register the server for a background task. + + Called at task-submission time (inside the child server's call_tool + context) so that background workers can resolve CurrentFastMCP() and + ctx.fastmcp to the child server for mounted tasks. + + The map is bounded to avoid unbounded growth in long-lived servers. + Evicted entries fall back to the ContextVar (parent server). + """ + _task_server_map[task_id] = weakref.ref(server) + while len(_task_server_map) > _TASK_SERVER_MAP_MAX_SIZE: + _task_server_map.popitem(last=False) + + _current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None) _current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None) _task_access_token: ContextVar[AccessToken | None] = ContextVar( "task_access_token", default=None ) +_task_http_headers: ContextVar[dict[str, str] | None] = ContextVar( + "task_http_headers", default=None +) # --- Docket availability check --- @@ -215,7 +250,7 @@ def require_docket(feature: str) -> None: try: from docket.dependencies import Progress as DocketProgress except ImportError: - DocketProgress = None # type: ignore[assignment] + DocketProgress = None # type: ignore[assignment] # ty:ignore[invalid-assignment] # --- Context utilities --- @@ -333,10 +368,10 @@ def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]: # Insert 'self' at the beginning of our new params self_param = next(iter(func_sig.parameters.values())) # Should be 'self' new_sig = func_sig.replace(parameters=[self_param, *new_params]) - fn.__func__.__signature__ = new_sig # type: ignore[union-attr] + fn.__func__.__signature__ = new_sig # type: ignore[union-attr] # ty:ignore[unresolved-attribute] else: new_sig = sig.replace(parameters=new_params) - fn.__signature__ = new_sig # type: ignore[attr-defined] + fn.__signature__ = new_sig # type: ignore[attr-defined] # ty:ignore[invalid-assignment] # Clear caches that may have cached the old signature # This ensures get_dependency_parameters and without_injected_parameters @@ -375,12 +410,28 @@ def get_context() -> Context: def get_server() -> FastMCP: """Get the current FastMCP server instance directly. + In a background-task worker, checks the task-server map first so that + mounted-child tasks resolve to the child server (not the parent that + started the worker). + Returns: The active FastMCP server Raises: RuntimeError: If no server in context """ + # In a task context, prefer the task-specific server mapping. + # This handles mounted-child tasks where _current_server is the parent. + task_info = get_task_context() + if task_info is not None: + ref = _task_server_map.get(task_info.task_id) + if ref is not None: + server = ref() + if server is not None: + return server + # Server was garbage collected, clean up + _task_server_map.pop(task_info.task_id, None) + server_ref = _current_server.get() if server_ref is None: raise RuntimeError("No FastMCP server instance in context") @@ -394,6 +445,8 @@ def get_http_request() -> Request: """Get the current HTTP request. Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. + In background tasks, returns a synthetic request populated with the + snapshotted headers from the originating HTTP request. """ # Try MCP SDK's request_ctx first (set during normal MCP request handling) request = None @@ -405,6 +458,29 @@ def get_http_request() -> Request: if request is None: request = _current_http_request.get() + # In Docket workers, restore a minimal request from the snapshotted headers. + if request is None: + task_headers = _task_http_headers.get() + if task_headers: + request = Request( + { + "type": "http", + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/", + "raw_path": b"/", + "query_string": b"", + "headers": [ + (name.encode("latin-1"), value.encode("latin-1")) + for name, value in task_headers.items() + ], + "client": None, + "server": None, + "root_path": "", + } + ) + if request is None: raise RuntimeError("No active HTTP request found.") return request @@ -580,7 +656,7 @@ def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]: new_sig = inspect.Signature(user_params) # Create async wrapper that handles dependency resolution - fn_is_async = inspect.iscoroutinefunction(fn) + fn_is_async = is_coroutine_function(fn) async def wrapper(**user_kwargs: Any) -> Any: async with resolve_dependencies(fn, user_kwargs) as resolved_kwargs: @@ -603,7 +679,7 @@ def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]: except Exception: resolved_hints = getattr(fn, "__annotations__", {}) - wrapper.__signature__ = new_sig # type: ignore[attr-defined] + wrapper.__signature__ = new_sig # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] wrapper.__annotations__ = { k: v for k, v in resolved_hints.items() if k not in exclude and k != "return" } @@ -768,6 +844,38 @@ async def _restore_task_access_token( return None +async def _restore_task_http_headers( + session_id: str, task_id: str +) -> Token[dict[str, str] | None] | None: + """Restore the HTTP header snapshot from Redis into a ContextVar.""" + docket = _current_docket.get() + if docket is None: + return None + + headers_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:http_headers") + try: + async with docket.redis() as redis: + headers_data = await redis.get(headers_key) + if headers_data is None: + return None + if isinstance(headers_data, bytes): + headers_data = headers_data.decode() + restored = json.loads(str(headers_data)) + if not isinstance(restored, dict): + return None + return _task_http_headers.set( + {str(name).lower(): str(value) for name, value in restored.items()} + ) + except Exception: + _logger.warning( + "Failed to restore HTTP headers for task %s:%s", + session_id, + task_id, + exc_info=True, + ) + return None + + async def _restore_task_origin_request_id(session_id: str, task_id: str) -> str | None: """Restore the origin request ID snapshot for a background task. @@ -808,6 +916,7 @@ class _CurrentContext(Dependency["Context"]): _context: Context | None = None _access_token_cv_token: Token[AccessToken | None] | None = None + _http_headers_cv_token: Token[dict[str, str] | None] | None = None async def __aenter__(self) -> Context: from fastmcp.server.context import Context, _current_context @@ -842,6 +951,11 @@ class _CurrentContext(Dependency["Context"]): task_info.session_id, task_info.task_id ) + # Restore HTTP headers snapshot from Redis (#3631) + self._http_headers_cv_token = await _restore_task_http_headers( + task_info.session_id, task_info.task_id + ) + return self._context # Neither foreground nor background context available @@ -862,6 +976,10 @@ class _CurrentContext(Dependency["Context"]): if self._access_token_cv_token is not None: _task_access_token.reset(self._access_token_cv_token) self._access_token_cv_token = None + # Clean up HTTP headers ContextVar + if self._http_headers_cv_token is not None: + _task_http_headers.reset(self._http_headers_cv_token) + self._http_headers_cv_token = None # Clean up if we created a context for background task if self._context is not None: await self._context.__aexit__(exc_type, exc_value, traceback) @@ -1034,13 +1152,7 @@ class _CurrentFastMCP(Dependency["FastMCP"]): """Async context manager for FastMCP server dependency.""" async def __aenter__(self) -> FastMCP: - server_ref = _current_server.get() - if server_ref is None: - raise RuntimeError("No FastMCP server instance in context") - server = server_ref() - if server is None: - raise RuntimeError("FastMCP server instance is no longer available") - return server + return get_server() async def __aexit__( self, @@ -1079,8 +1191,20 @@ def CurrentFastMCP() -> FastMCP: class _CurrentRequest(Dependency[Request]): """Async context manager for HTTP Request dependency.""" + _task_http_headers_cv_token: Token[dict[str, str] | None] | None = None + async def __aenter__(self) -> Request: - return get_http_request() + try: + return get_http_request() + except RuntimeError: + task_info = get_task_context() + if task_info is None: + raise + if _task_http_headers.get() is None: + self._task_http_headers_cv_token = await _restore_task_http_headers( + task_info.session_id, task_info.task_id + ) + return get_http_request() async def __aexit__( self, @@ -1088,7 +1212,9 @@ class _CurrentRequest(Dependency[Request]): exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - pass + if self._task_http_headers_cv_token is not None: + _task_http_headers.reset(self._task_http_headers_cv_token) + self._task_http_headers_cv_token = None def CurrentRequest() -> Request: @@ -1120,7 +1246,15 @@ def CurrentRequest() -> Request: class _CurrentHeaders(Dependency[dict[str, str]]): """Async context manager for HTTP Headers dependency.""" + _task_http_headers_cv_token: Token[dict[str, str] | None] | None = None + async def __aenter__(self) -> dict[str, str]: + if _task_http_headers.get() is None: + task_info = get_task_context() + if task_info is not None: + self._task_http_headers_cv_token = await _restore_task_http_headers( + task_info.session_id, task_info.task_id + ) return get_http_headers(include={"authorization"}) async def __aexit__( @@ -1129,7 +1263,9 @@ class _CurrentHeaders(Dependency[dict[str, str]]): exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - pass + if self._task_http_headers_cv_token is not None: + _task_http_headers.reset(self._task_http_headers_cv_token) + self._task_http_headers_cv_token = None def CurrentHeaders() -> dict[str, str]: diff --git a/src/fastmcp/server/elicitation.py b/src/fastmcp/server/elicitation.py index 6f8a050d4..caa53049e 100644 --- a/src/fastmcp/server/elicitation.py +++ b/src/fastmcp/server/elicitation.py @@ -41,7 +41,7 @@ class ElicitationJsonSchema(GenerateJsonSchema): Optionally adds enumNames for better UI display when available. """ - def generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue: # type: ignore[override] + def generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue: # type: ignore[override] # ty:ignore[invalid-method-override] """Override to prevent ref generation for enums and handle list schemas.""" # For enum schemas, bypass the ref mechanism entirely if schema["type"] == "enum": @@ -61,7 +61,7 @@ class ElicitationJsonSchema(GenerateJsonSchema): # Check if items are enum/Literal if items_schema and items_schema.get("type") == "enum": # Generate array with enum items - items = self.enum_schema(items_schema) # type: ignore[arg-type] + items = self.enum_schema(items_schema) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] # If items have oneOf pattern, convert to anyOf for multi-select per SEP-1330 if "oneOf" in items: items = {"anyOf": items["oneOf"]} @@ -231,8 +231,8 @@ def _parse_list_syntax(lst: list[Any]) -> ElicitConfig: if lst and all(isinstance(item, str) for item in lst): # Construct Literal type from tuple - use cast since we can't construct Literal dynamically # but we know the values are all strings - choice_literal: type[Any] = cast(type[Any], Literal[tuple(lst)]) # type: ignore[valid-type] - wrapped = ScalarElicitationType[choice_literal] # type: ignore[valid-type] + choice_literal: type[Any] = cast(type[Any], Literal[tuple(lst)]) # type: ignore[valid-type] # ty:ignore[invalid-type-form] + wrapped = ScalarElicitationType[choice_literal] # type: ignore[valid-type] # ty:ignore[invalid-type-form] return ElicitConfig( schema=get_elicitation_schema(wrapped), response_type=wrapped, diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index c29c39ec8..e60ae6061 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -125,7 +125,6 @@ def create_base_app( A Starlette application """ # Always add RequestContextMiddleware as the outermost middleware - # TODO(ty): remove type ignore when ty supports Starlette Middleware typing middleware.insert(0, Middleware(RequestContextMiddleware)) # type: ignore[arg-type] return StarletteWithLifespan( diff --git a/src/fastmcp/server/low_level.py b/src/fastmcp/server/low_level.py index 2bc004ff3..36255f4c7 100644 --- a/src/fastmcp/server/low_level.py +++ b/src/fastmcp/server/low_level.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any import anyio import mcp.types from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from mcp import McpError +from mcp import LoggingLevel, McpError from mcp.server.lowlevel.server import ( LifespanResultT, NotificationOptions, @@ -24,7 +24,7 @@ from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder from pydantic import AnyUrl -from fastmcp.server.apps import UI_EXTENSION_ID +from fastmcp.apps.config import UI_EXTENSION_ID from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: @@ -40,7 +40,9 @@ class MiddlewareServerSession(ServerSession): super().__init__(*args, **kwargs) self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp) # Task group for subscription tasks (set during session run) - self._subscription_task_group: anyio.TaskGroup | None = None # type: ignore[valid-type] + self._subscription_task_group: anyio.TaskGroup | None = None # type: ignore[valid-type] # ty:ignore[invalid-type-form] + # Minimum logging level requested by the client via logging/setLevel + self._minimum_logging_level: LoggingLevel | None = None @property def fastmcp(self) -> FastMCP: @@ -103,7 +105,7 @@ class MiddlewareServerSession(ServerSession): captured_response = response return await original_respond(response) - responder.respond = capturing_respond # type: ignore[method-assign] + responder.respond = capturing_respond # type: ignore[method-assign] # ty:ignore[invalid-assignment] async def call_original_handler( ctx: MiddlewareContext, @@ -144,6 +146,7 @@ class MiddlewareServerSession(ServerSession): "Cannot send error response as response was already sent.", exc_info=e, ) + return None # Fall through to default handling (task methods now handled via registered handlers) return await super()._received_request(responder) diff --git a/src/fastmcp/server/middleware/authorization.py b/src/fastmcp/server/middleware/authorization.py index 4d3914a18..19b050370 100644 --- a/src/fastmcp/server/middleware/authorization.py +++ b/src/fastmcp/server/middleware/authorization.py @@ -29,8 +29,8 @@ from collections.abc import Sequence import mcp.types as mt from fastmcp.exceptions import AuthorizationError -from fastmcp.prompts.prompt import Prompt, PromptResult -from fastmcp.resources.resource import Resource, ResourceResult +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, @@ -43,7 +43,7 @@ from fastmcp.server.middleware.middleware import ( Middleware, MiddlewareContext, ) -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult logger = logging.getLogger(__name__) diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 670c30a44..cb2b49866 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -1,5 +1,6 @@ """A middleware for response caching.""" +import hashlib from collections.abc import Sequence from logging import Logger from typing import Any, TypedDict @@ -17,10 +18,10 @@ from key_value.aio.wrappers.statistics.wrapper import ( from pydantic import Field from typing_extensions import NotRequired, Self, override -from fastmcp.prompts.prompt import Message, Prompt, PromptResult -from fastmcp.resources.resource import Resource, ResourceContent, ResourceResult +from fastmcp.prompts.base import Message, Prompt, PromptResult +from fastmcp.resources.base import Resource, ResourceContent, ResourceResult from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import FastMCPBaseModel @@ -101,7 +102,12 @@ class CachableMessage(FastMCPBaseModel): """A wrapper for Message that can be cached.""" role: str - content: mcp.types.TextContent | mcp.types.EmbeddedResource + content: ( + mcp.types.TextContent + | mcp.types.ImageContent + | mcp.types.AudioContent + | mcp.types.EmbeddedResource + ) class CachablePromptResult(FastMCPBaseModel): @@ -127,7 +133,7 @@ class CachablePromptResult(FastMCPBaseModel): def unwrap(self) -> PromptResult: return PromptResult( messages=[ - Message(content=m.content, role=m.role) # type: ignore[arg-type] + Message(content=m.content, role=m.role) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] for m in self.messages ], description=self.description, @@ -411,7 +417,7 @@ class ResponseCachingMiddleware(Middleware): ) is False or not self._matches_tool_cache_settings(tool_name=tool_name): return await call_next(context=context) - cache_key: str = f"{tool_name}:{_get_arguments_str(context.message.arguments)}" + cache_key: str = _make_call_tool_cache_key(msg=context.message) if cached_value := await self._call_tool_cache.get(key=cache_key): return cached_value.unwrap() @@ -440,7 +446,7 @@ class ResponseCachingMiddleware(Middleware): if self._read_resource_settings.get("enabled") is False: return await call_next(context=context) - cache_key: str = str(context.message.uri) + cache_key: str = _make_read_resource_cache_key(msg=context.message) cached_value: CachableResourceResult | None if cached_value := await self._read_resource_cache.get(key=cache_key): @@ -468,20 +474,21 @@ class ResponseCachingMiddleware(Middleware): if self._get_prompt_settings.get("enabled") is False: return await call_next(context=context) - cache_key: str = f"{context.message.name}:{_get_arguments_str(arguments=context.message.arguments)}" + cache_key: str = _make_get_prompt_cache_key(msg=context.message) if cached_value := await self._get_prompt_cache.get(key=cache_key): return cached_value.unwrap() value: PromptResult = await call_next(context=context) + cached_value = CachablePromptResult.wrap(value) await self._get_prompt_cache.put( key=cache_key, - value=CachablePromptResult.wrap(value), + value=cached_value, ttl=self._get_prompt_settings.get("ttl", ONE_HOUR_IN_SECONDS), ) - return value + return cached_value.unwrap() def _matches_tool_cache_settings(self, tool_name: str) -> bool: """Check if the tool matches the cache settings for tool calls.""" @@ -519,3 +526,27 @@ def _get_arguments_str(arguments: dict[str, Any] | None) -> str: except TypeError: return repr(arguments) + + +def _hash_cache_key(value: str) -> str: + """Build a fixed-length SHA-256 cache key from request-derived input.""" + + return hashlib.sha256(value.encode()).hexdigest() + + +def _make_call_tool_cache_key(msg: mcp.types.CallToolRequestParams) -> str: + """Make a cache key for a tool call using a stable hash of name and arguments.""" + + return _hash_cache_key(f"{msg.name}:{_get_arguments_str(msg.arguments)}") + + +def _make_read_resource_cache_key(msg: mcp.types.ReadResourceRequestParams) -> str: + """Make a cache key for a resource read using a stable hash of URI.""" + + return _hash_cache_key(str(msg.uri)) + + +def _make_get_prompt_cache_key(msg: mcp.types.GetPromptRequestParams) -> str: + """Make a cache key for a prompt get using a stable hash of name and arguments.""" + + return _hash_cache_key(f"{msg.name}:{_get_arguments_str(msg.arguments)}") diff --git a/src/fastmcp/server/middleware/dereference.py b/src/fastmcp/server/middleware/dereference.py index 89150d655..0c27585bc 100644 --- a/src/fastmcp/server/middleware/dereference.py +++ b/src/fastmcp/server/middleware/dereference.py @@ -8,7 +8,7 @@ from typing_extensions import override from fastmcp.resources.template import ResourceTemplate from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.json_schema import dereference_refs diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index af2eff4b4..33825df47 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -56,7 +56,7 @@ class BaseLoggingMiddleware(Middleware): def _create_before_message( self, context: MiddlewareContext[Any] ) -> dict[str, str | int | float]: - message = { + message: dict[str, str | int | float] = { "event": context.type + "_start", "method": context.method or "unknown", "source": context.source, diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py index 8022ee9dd..ce7f13567 100644 --- a/src/fastmcp/server/middleware/middleware.py +++ b/src/fastmcp/server/middleware/middleware.py @@ -17,10 +17,10 @@ from typing import ( import mcp.types as mt from typing_extensions import TypeVar -from fastmcp.prompts.prompt import Prompt, PromptResult -from fastmcp.resources.resource import Resource, ResourceResult +from fastmcp.prompts.base import Prompt, PromptResult +from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult if TYPE_CHECKING: from fastmcp.server.context import Context diff --git a/src/fastmcp/server/middleware/ping.py b/src/fastmcp/server/middleware/ping.py index 3ee61dac2..a8e35bf2d 100644 --- a/src/fastmcp/server/middleware/ping.py +++ b/src/fastmcp/server/middleware/ping.py @@ -53,7 +53,7 @@ class PingMiddleware(Middleware): async with self._lock: if session_id not in self._active_sessions: # _subscription_task_group is added by MiddlewareServerSession - tg = session._subscription_task_group # type: ignore[attr-defined] + tg = session._subscription_task_group # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] if tg is not None: self._active_sessions.add(session_id) tg.start_soon(self._ping_loop, session, session_id) diff --git a/src/fastmcp/server/middleware/response_limiting.py b/src/fastmcp/server/middleware/response_limiting.py index df83e81a0..3afaf0705 100644 --- a/src/fastmcp/server/middleware/response_limiting.py +++ b/src/fastmcp/server/middleware/response_limiting.py @@ -8,7 +8,7 @@ import mcp.types as mt import pydantic_core from mcp.types import TextContent -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.base import ToolResult from .middleware import CallNext, Middleware, MiddlewareContext diff --git a/src/fastmcp/server/middleware/tool_injection.py b/src/fastmcp/server/middleware/tool_injection.py index 8f3ef0b43..7dfd59694 100644 --- a/src/fastmcp/server/middleware/tool_injection.py +++ b/src/fastmcp/server/middleware/tool_injection.py @@ -1,5 +1,6 @@ """A middleware for injecting tools into the MCP server context.""" +import warnings from collections.abc import Sequence from logging import Logger from typing import Annotated, Any @@ -9,10 +10,12 @@ from mcp.types import Prompt from pydantic import AnyUrl from typing_extensions import override -from fastmcp.resources.resource import ResourceResult +import fastmcp +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.resources.base import ResourceResult from fastmcp.server.context import Context from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.logging import get_logger logger: Logger = get_logger(name=__name__) @@ -78,9 +81,20 @@ get_prompt_tool = Tool.from_function( class PromptToolMiddleware(ToolInjectionMiddleware): - """A middleware for injecting prompts as tools into the context.""" + """A middleware for injecting prompts as tools into the context. + + .. deprecated:: + Use ``fastmcp.server.transforms.PromptsAsTools`` instead. + """ def __init__(self) -> None: + if fastmcp.settings.deprecation_warnings: + warnings.warn( + "PromptToolMiddleware is deprecated. Use the PromptsAsTools transform instead: " + "from fastmcp.server.transforms import PromptsAsTools", + FastMCPDeprecationWarning, + stacklevel=2, + ) tools: list[Tool] = [list_prompts_tool, get_prompt_tool] super().__init__(tools=tools) @@ -109,8 +123,19 @@ read_resource_tool = Tool.from_function( class ResourceToolMiddleware(ToolInjectionMiddleware): - """A middleware for injecting resources as tools into the context.""" + """A middleware for injecting resources as tools into the context. + + .. deprecated:: + Use ``fastmcp.server.transforms.ResourcesAsTools`` instead. + """ def __init__(self) -> None: + if fastmcp.settings.deprecation_warnings: + warnings.warn( + "ResourceToolMiddleware is deprecated. Use the ResourcesAsTools transform instead: " + "from fastmcp.server.transforms import ResourcesAsTools", + FastMCPDeprecationWarning, + stacklevel=2, + ) tools: list[Tool] = [list_resources_tool, read_resource_tool] super().__init__(tools=tools) diff --git a/src/fastmcp/server/mixins/lifespan.py b/src/fastmcp/server/mixins/lifespan.py index 267736a83..a6c0cb9ed 100644 --- a/src/fastmcp/server/mixins/lifespan.py +++ b/src/fastmcp/server/mixins/lifespan.py @@ -8,6 +8,7 @@ from collections.abc import AsyncIterator from contextlib import AsyncExitStack, asynccontextmanager, suppress from typing import TYPE_CHECKING, Any +import anyio from uncalled_for import SharedContext import fastmcp @@ -104,6 +105,7 @@ class LifespanMixin: "concurrency": settings.docket.concurrency, "redelivery_timeout": settings.docket.redelivery_timeout, "reconnection_delay": settings.docket.reconnection_delay, + "minimum_check_interval": settings.docket.minimum_check_interval, } if settings.docket.worker_name: worker_kwargs["name"] = settings.docket.worker_name @@ -136,30 +138,56 @@ class LifespanMixin: @asynccontextmanager async def _lifespan_manager(self: FastMCP) -> AsyncIterator[None]: - if self._lifespan_result_set: - yield + async with self._lifespan_lock: + if self._lifespan_result_set: + self._lifespan_ref_count += 1 + should_enter_lifespan = False + else: + self._lifespan_ref_count = 1 + should_enter_lifespan = True + + if not should_enter_lifespan: + try: + yield + finally: + async with self._lifespan_lock: + self._lifespan_ref_count -= 1 + if self._lifespan_ref_count == 0: + self._lifespan_result_set = False + self._lifespan_result = None return - async with ( - self._lifespan(self) as user_lifespan_result, - self._docket_lifespan(), - ): + # Use an explicit AsyncExitStack so we can shield teardown from + # cancellation. Without this, Ctrl-C causes CancelledError to + # propagate into lifespan finally blocks, preventing any async + # cleanup (e.g. closing DB connections, flushing buffers). + stack = AsyncExitStack() + try: + user_lifespan_result = await stack.enter_async_context(self._lifespan(self)) + await stack.enter_async_context(self._docket_lifespan()) + self._lifespan_result = user_lifespan_result self._lifespan_result_set = True - async with AsyncExitStack[bool | None]() as stack: - # Start lifespans for all providers - for provider in self.providers: - await stack.enter_async_context(provider.lifespan()) + # Start lifespans for all providers + for provider in self.providers: + await stack.enter_async_context(provider.lifespan()) - self._started.set() - try: - yield - finally: - self._started.clear() - - self._lifespan_result_set = False - self._lifespan_result = None + self._started.set() + try: + yield + finally: + self._started.clear() + finally: + try: + with anyio.CancelScope(shield=True): + await stack.aclose() + finally: + async with self._lifespan_lock: + self._lifespan_ref_count -= 1 + if self._lifespan_ref_count == 0: + self._lifespan_result_set = False + self._lifespan_result = None def _setup_task_protocol_handlers(self: FastMCP) -> None: """Register SEP-1686 task protocol handlers with SDK. diff --git a/src/fastmcp/server/mixins/mcp_operations.py b/src/fastmcp/server/mixins/mcp_operations.py index 440fba4b3..70bd65607 100644 --- a/src/fastmcp/server/mixins/mcp_operations.py +++ b/src/fastmcp/server/mixins/mcp_operations.py @@ -79,6 +79,7 @@ class MCPOperationsMixin: ) self._mcp_server.read_resource()(self._read_resource_mcp) self._mcp_server.get_prompt()(self._get_prompt_mcp) + self._mcp_server.set_logging_level()(self._set_logging_level_mcp) # Register SEP-1686 task protocol handlers self._setup_task_protocol_handlers() @@ -218,7 +219,7 @@ class MCPOperationsMixin: task_meta: TaskMeta | None = None try: ctx = server._mcp_server.request_context - # Extract version from request-level _meta.fastmcp.version + # Extract version from _meta.fastmcp if ctx.meta: meta_dict = ctx.meta.model_dump(exclude_none=True) version_str = meta_dict.get("fastmcp", {}).get("version") @@ -352,3 +353,21 @@ class MCPOperationsMixin: raise NotFoundError(f"Unknown prompt: {name!r}") from e except NotFoundError: raise + + async def _set_logging_level_mcp(self, level: mcp.types.LoggingLevel) -> None: + """Handle MCP 'logging/setLevel' requests. + + Stores the requested minimum log level on the session so that + subsequent log messages below this level are suppressed. + """ + from fastmcp.server.low_level import MiddlewareServerSession + + server = cast("FastMCP", self) + logger.debug(f"[{server.name}] Handler called: set_logging_level %s", level) + try: + ctx = server._mcp_server.request_context + session = ctx.session + if isinstance(session, MiddlewareServerSession): + session._minimum_logging_level = level + except LookupError: + pass diff --git a/src/fastmcp/server/mixins/transport.py b/src/fastmcp/server/mixins/transport.py index 833b5e385..10223f38a 100644 --- a/src/fastmcp/server/mixins/transport.py +++ b/src/fastmcp/server/mixins/transport.py @@ -22,6 +22,9 @@ from fastmcp.server.http import ( create_sse_app, create_streamable_http_app, ) +from fastmcp.server.providers.base import Provider +from fastmcp.server.providers.fastmcp_provider import FastMCPProvider +from fastmcp.server.providers.wrapped_provider import _WrappedProvider from fastmcp.utilities.cli import log_server_banner from fastmcp.utilities.logging import get_logger, temporary_log_level @@ -145,15 +148,38 @@ class TransportMixin: return decorator def _get_additional_http_routes(self: FastMCP) -> list[BaseRoute]: - """Get all additional HTTP routes including from providers. + """Get all additional HTTP routes including from mounted servers. - Returns a list of all custom HTTP routes from this server and - from all providers that have HTTP routes (e.g., FastMCPProvider). + Collects custom HTTP routes registered via ``@server.custom_route()`` + from this server **and** from any FastMCP servers reachable through + mounted providers (recursively). This ensures that routes defined on + a child server are forwarded to the parent's HTTP app when using + ``server.mount(child)``. + + Note: + When path collisions occur between a parent and a mounted child, + the parent's routes take precedence because they appear first in + the returned list. Returns: - List of Starlette BaseRoute objects + List of Starlette Route objects """ - return list(self._additional_http_routes) + routes: list[BaseRoute] = list(self._additional_http_routes) + + def _unwrap_provider(provider: Provider) -> Provider: + """Unwrap _WrappedProvider layers to find the inner provider.""" + while isinstance(provider, _WrappedProvider): + provider = provider._inner + return provider + + for provider in self.providers: + inner = _unwrap_provider(provider) + if isinstance(inner, FastMCPProvider): + # Recurse into the mounted server to collect its routes + # (and any routes from servers mounted on *it*). + routes.extend(inner.server._get_additional_http_routes()) + + return routes async def run_stdio_async( self: FastMCP, @@ -255,7 +281,7 @@ class TransportMixin: uvicorn_config_from_user = uvicorn_config or {} config_kwargs: dict[str, Any] = { - "timeout_graceful_shutdown": 0, + "timeout_graceful_shutdown": 2, "lifespan": "on", "ws": "websockets-sansio", } diff --git a/src/fastmcp/server/openapi/__init__.py b/src/fastmcp/server/openapi/__init__.py index 5c19c90eb..3ea81d109 100644 --- a/src/fastmcp/server/openapi/__init__.py +++ b/src/fastmcp/server/openapi/__init__.py @@ -20,10 +20,12 @@ FastMCPOpenAPI is still available but deprecated. import warnings +from fastmcp.exceptions import FastMCPDeprecationWarning + warnings.warn( "fastmcp.server.openapi is deprecated. " "Import from fastmcp.server.providers.openapi instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) diff --git a/src/fastmcp/server/openapi/components.py b/src/fastmcp/server/openapi/components.py index 06a381a9e..ce1eeaf7d 100644 --- a/src/fastmcp/server/openapi/components.py +++ b/src/fastmcp/server/openapi/components.py @@ -7,10 +7,12 @@ from __future__ import annotations import warnings +from fastmcp.exceptions import FastMCPDeprecationWarning + warnings.warn( "fastmcp.server.openapi.components is deprecated. " "Import from fastmcp.server.providers.openapi instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) diff --git a/src/fastmcp/server/openapi/routing.py b/src/fastmcp/server/openapi/routing.py index 9acad5e84..309e503ca 100644 --- a/src/fastmcp/server/openapi/routing.py +++ b/src/fastmcp/server/openapi/routing.py @@ -8,6 +8,8 @@ import warnings +from fastmcp.exceptions import FastMCPDeprecationWarning + # Backwards compatibility - export everything that was previously public __all__ = [ "DEFAULT_ROUTE_MAPPINGS", @@ -21,7 +23,7 @@ __all__ = [ warnings.warn( "fastmcp.server.openapi.routing is deprecated. " "Import from fastmcp.server.providers.openapi instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) diff --git a/src/fastmcp/server/openapi/server.py b/src/fastmcp/server/openapi/server.py index 3171ca763..a7292129d 100644 --- a/src/fastmcp/server/openapi/server.py +++ b/src/fastmcp/server/openapi/server.py @@ -18,6 +18,7 @@ from typing import Any import httpx +from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.server.providers.openapi import ( ComponentFn, OpenAPIProvider, @@ -90,7 +91,7 @@ class FastMCPOpenAPI(FastMCP): "FastMCPOpenAPI is deprecated. Use FastMCP with OpenAPIProvider instead:\n" " provider = OpenAPIProvider(openapi_spec=spec, client=client)\n" " mcp = FastMCP('name', providers=[provider])", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) diff --git a/src/fastmcp/server/providers/aggregate.py b/src/fastmcp/server/providers/aggregate.py index 674e2b2c3..c881595e5 100644 --- a/src/fastmcp/server/providers/aggregate.py +++ b/src/fastmcp/server/providers/aggregate.py @@ -33,10 +33,10 @@ from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.versions import VersionSpec, version_sort_key if TYPE_CHECKING: - from fastmcp.prompts.prompt import Prompt - from fastmcp.resources.resource import Resource + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool logger = logging.getLogger(__name__) @@ -166,7 +166,20 @@ class AggregateProvider(Provider): *[p.get_tool(name, version) for p in self.providers], return_exceptions=True, ) - return self._get_highest_version_result(results, f"get_tool({name!r})") # type: ignore[return-value] + return self._get_highest_version_result(results, f"get_tool({name!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type] + + async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: + """Query all child providers for an app tool.""" + results = await gather( + *[p.get_app_tool(app_name, tool_name) for p in self.providers], + return_exceptions=True, + ) + for r in results: + if isinstance(r, BaseException): + continue + if r is not None: + return r + return None # ------------------------------------------------------------------------- # Resources @@ -188,7 +201,7 @@ class AggregateProvider(Provider): *[p.get_resource(uri, version) for p in self.providers], return_exceptions=True, ) - return self._get_highest_version_result(results, f"get_resource({uri!r})") # type: ignore[return-value] + return self._get_highest_version_result(results, f"get_resource({uri!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type] # ------------------------------------------------------------------------- # Resource Templates @@ -211,8 +224,8 @@ class AggregateProvider(Provider): return_exceptions=True, ) return self._get_highest_version_result( - results, f"get_resource_template({uri!r})" - ) # type: ignore[return-value] + list(results), f"get_resource_template({uri!r})" + ) # type: ignore[return-value] # ty:ignore[invalid-return-type] # ------------------------------------------------------------------------- # Prompts @@ -234,7 +247,7 @@ class AggregateProvider(Provider): *[p.get_prompt(name, version) for p in self.providers], return_exceptions=True, ) - return self._get_highest_version_result(results, f"get_prompt({name!r})") # type: ignore[return-value] + return self._get_highest_version_result(results, f"get_prompt({name!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type] # ------------------------------------------------------------------------- # Tasks diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py index ccd48765f..5f6e59cdd 100644 --- a/src/fastmcp/server/providers/base.py +++ b/src/fastmcp/server/providers/base.py @@ -35,11 +35,11 @@ from typing import TYPE_CHECKING, Literal, cast from typing_extensions import Self -from fastmcp.prompts.prompt import Prompt -from fastmcp.resources.resource import Resource +from fastmcp.prompts.base import Prompt +from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.transforms.visibility import Visibility -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.async_utils import gather from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.versions import VersionSpec, version_sort_key @@ -175,6 +175,32 @@ class Provider: return await chain(name, version=version) + async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: + """Look up an app-visible tool by original name, bypassing transforms. + + Searches for a tool named ``tool_name`` tagged with the given app + name. Skips the transform chain entirely. + + Returns: + The tool if found and tagged with the given app name, else None. + """ + tool = await self._get_tool(tool_name) + if tool is not None: + meta = tool.meta or {} + fastmcp_meta = meta.get("fastmcp") + ui_meta = meta.get("ui") + # Must match app name AND have app visibility (not model-only) + visibility = ( + ui_meta.get("visibility", []) if isinstance(ui_meta, dict) else [] + ) + if ( + isinstance(fastmcp_meta, dict) + and fastmcp_meta.get("app") == app_name + and "app" in visibility + ): + return tool + return None + async def list_resources(self) -> Sequence[Resource]: """List resources with all transforms applied. @@ -316,7 +342,7 @@ class Provider: matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] async def _list_resources(self) -> Sequence[Resource]: """Return all available resources. @@ -347,7 +373,7 @@ class Provider: matching = [r for r in matching if version.matches(r.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """Return all available resource templates. @@ -378,7 +404,7 @@ class Provider: matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] async def _list_prompts(self) -> Sequence[Prompt]: """Return all available prompts. @@ -409,7 +435,7 @@ class Provider: matching = [p for p in matching if version.matches(p.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] # ------------------------------------------------------------------------- # Task registration diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index 92038c540..f13b241c1 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -19,13 +19,13 @@ from urllib.parse import quote import mcp.types from mcp.types import AnyUrl -from fastmcp.prompts.prompt import Prompt, PromptResult -from fastmcp.resources.resource import Resource, ResourceResult +from fastmcp.prompts.base import Prompt, PromptResult +from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers.base import Provider from fastmcp.server.tasks.config import TaskMeta from fastmcp.server.telemetry import delegate_span -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.versions import VersionSpec @@ -104,6 +104,7 @@ class FastMCPProviderTool(Tool): tags=tool.tags, annotations=tool.annotations, task_config=tool.task_config, + execution=tool.execution, meta=tool.get_meta(), title=tool.title, icons=tool.icons, @@ -141,7 +142,10 @@ class FastMCPProviderTool(Tool): self._original_name or "", "FastMCPProvider", self._original_name or "" ): return await self._server.call_tool( - self._original_name, arguments, version=version, task_meta=task_meta + self._original_name, + arguments, + version=version, + task_meta=task_meta, ) async def run(self, arguments: dict[str, Any]) -> ToolResult: @@ -562,6 +566,18 @@ class FastMCPProvider(Provider): return None return FastMCPProviderTool.wrap(self.server, raw_tool) + async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: + """Delegate to nested server's get_app_tool, wrapping for middleware.""" + raw_tool = await self.server.get_app_tool(app_name, tool_name) + if raw_tool is None: + return None + wrapped = FastMCPProviderTool.wrap(self.server, raw_tool) + # Use the ___-prefixed name so the inner server's call_tool also + # takes the app-tool bypass path (app-only tools are hidden from + # normal get_tool visibility filtering). + wrapped._original_name = f"{app_name}___{tool_name}" + return wrapped + # ------------------------------------------------------------------------- # Resource methods # ------------------------------------------------------------------------- diff --git a/src/fastmcp/server/providers/filesystem.py b/src/fastmcp/server/providers/filesystem.py index 2306a48a7..774021dca 100644 --- a/src/fastmcp/server/providers/filesystem.py +++ b/src/fastmcp/server/providers/filesystem.py @@ -31,12 +31,12 @@ import asyncio from collections.abc import Sequence from pathlib import Path -from fastmcp.prompts.prompt import Prompt -from fastmcp.resources.resource import Resource +from fastmcp.prompts.base import Prompt +from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers.filesystem_discovery import discover_and_import from fastmcp.server.providers.local_provider import LocalProvider -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.versions import VersionSpec diff --git a/src/fastmcp/server/providers/filesystem_discovery.py b/src/fastmcp/server/providers/filesystem_discovery.py index 96fe81b81..db8e0ce83 100644 --- a/src/fastmcp/server/providers/filesystem_discovery.py +++ b/src/fastmcp/server/providers/filesystem_discovery.py @@ -8,6 +8,8 @@ This module provides functions to: from __future__ import annotations +import contextlib +import hashlib import importlib.util import sys from dataclasses import dataclass, field @@ -68,10 +70,16 @@ def _is_package_dir(directory: Path) -> bool: return (directory / "__init__.py").exists() -def _find_package_root(file_path: Path) -> Path | None: +def _find_package_root(file_path: Path, stop_at: Path | None = None) -> Path | None: """Find the root of the package containing this file. - Walks up the directory tree until we find a directory without __init__.py. + Walks up the directory tree until we find a directory without __init__.py, + but never above stop_at (the provider root). This prevents escaping into + ancestor packages when the provider is nested inside a larger Python project. + + Args: + file_path: Path to the Python file. + stop_at: Do not walk above this directory. Typically the provider root. Returns: The package root directory, or None if not in a package. @@ -80,6 +88,8 @@ def _find_package_root(file_path: Path) -> Path | None: package_root = None while current != current.parent: # Stop at filesystem root + if stop_at is not None and current == stop_at.parent: + break # Don't escape above the provider root if _is_package_dir(current): package_root = current current = current.parent @@ -106,15 +116,22 @@ def _compute_module_name(file_path: Path, package_root: Path) -> str: return ".".join(parts) -def import_module_from_file(file_path: Path) -> ModuleType: +def import_module_from_file( + file_path: Path, provider_root: Path | None = None +) -> ModuleType: """Import a Python file as a module. If the file is part of a package (directory has __init__.py), imports it as a proper package member (relative imports work). Otherwise, imports directly using spec_from_file_location. + sys.path is modified only for the duration of the import and restored + immediately after, so no permanent pollution occurs. + Args: file_path: Path to the Python file. + provider_root: The provider's root directory. Prevents package root + discovery from walking above this boundary into ancestor packages. Returns: The imported module. @@ -123,22 +140,24 @@ def import_module_from_file(file_path: Path) -> ModuleType: ImportError: If the module cannot be imported. """ file_path = file_path.resolve() + if provider_root is not None: + provider_root = provider_root.resolve() # Check if this file is part of a package - package_root = _find_package_root(file_path) + package_root = _find_package_root(file_path, stop_at=provider_root) if package_root is not None: # Import as part of a package module_name = _compute_module_name(file_path, package_root) - # Ensure package root's parent is in sys.path + # Temporarily add package root's parent to sys.path for the import package_parent = str(package_root.parent) - if package_parent not in sys.path: + path_added = package_parent not in sys.path + if path_added: sys.path.insert(0, package_parent) - # Import using standard import machinery - # If already imported, reload to pick up changes (for reload mode) try: + # If already imported, reload to pick up changes (for reload mode) if module_name in sys.modules: return importlib.reload(sys.modules[module_name]) return importlib.import_module(module_name) @@ -146,30 +165,71 @@ def import_module_from_file(file_path: Path) -> ModuleType: raise ImportError( f"Failed to import {module_name} from {file_path}: {e}" ) from e + finally: + if path_added: + with contextlib.suppress(ValueError): + sys.path.remove(package_parent) else: # Import directly using spec_from_file_location - module_name = file_path.stem - - # Ensure parent directory is in sys.path for imports + stem = file_path.stem parent_dir = str(file_path.parent) - if parent_dir not in sys.path: + + # Determine the sys.modules key. Prefer the bare stem (so that sibling + # imports like `import helpers` resolve correctly), but fall back to a + # private collision-safe key if the bare stem is already claimed by + # something else (stdlib, a third-party package, or another provider file + # from a different directory). + existing = sys.modules.get(stem) + if existing is not None and getattr(existing, "__file__", None) != str( + file_path + ): + module_name = f"_fastmcp_{stem}_{hashlib.sha1(str(file_path).encode()).hexdigest()[:12]}" + else: + module_name = stem + + # Temporarily add parent to sys.path so module-level sibling imports resolve. + # Safe to remove after exec_module: all top-level imports are resolved by then, + # and sibling files imported as side effects are already in sys.modules. + path_added = parent_dir not in sys.path + if path_added: sys.path.insert(0, parent_dir) - spec = importlib.util.spec_from_file_location(module_name, file_path) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load spec for {file_path}") - - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - try: - spec.loader.exec_module(module) - except Exception as e: - # Clean up sys.modules on failure - sys.modules.pop(module_name, None) - raise ImportError(f"Failed to execute module {file_path}: {e}") from e + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load spec for {file_path}") - return module + existing = sys.modules.get(module_name) + if existing is not None: + # Re-exec in place rather than importlib.reload: reload() re-finds + # the module by name via sys.path, which fails for private keys + # (the file is tool.py, not _fastmcp_tool_xxx.py). + existing.__spec__ = spec + existing.__loader__ = spec.loader + existing.__file__ = str(file_path) + try: + spec.loader.exec_module(existing) + except Exception as e: + raise ImportError( + f"Failed to reload module {file_path}: {e}" + ) from e + return existing + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + + try: + spec.loader.exec_module(module) + except Exception as e: + # Clean up sys.modules on failure + sys.modules.pop(module_name, None) + raise ImportError(f"Failed to execute module {file_path}: {e}") from e + + return module + finally: + if path_added: + with contextlib.suppress(ValueError): + sys.path.remove(parent_dir) def extract_components(module: ModuleType) -> list[FastMCPComponent]: @@ -189,14 +249,14 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]: import inspect from fastmcp.decorators import get_fastmcp_meta + from fastmcp.prompts.base import Prompt from fastmcp.prompts.function_prompt import PromptMeta - from fastmcp.prompts.prompt import Prompt + from fastmcp.resources.base import Resource from fastmcp.resources.function_resource import ResourceMeta - from fastmcp.resources.resource import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.dependencies import without_injected_parameters + from fastmcp.tools.base import Tool from fastmcp.tools.function_tool import ToolMeta - from fastmcp.tools.tool import Tool component_types = (Tool, Resource, ResourceTemplate, Prompt) components: list[FastMCPComponent] = [] @@ -224,6 +284,7 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]: tool = Tool.from_function( obj, name=meta.name, + version=meta.version, title=meta.title, description=meta.description, icons=meta.icons, @@ -248,6 +309,7 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]: fn=obj, uri_template=meta.uri, name=meta.name, + version=meta.version, title=meta.title, description=meta.description, icons=meta.icons, @@ -263,6 +325,7 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]: fn=obj, uri=meta.uri, name=meta.name, + version=meta.version, title=meta.title, description=meta.description, icons=meta.icons, @@ -279,6 +342,7 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]: prompt = Prompt.from_function( obj, name=meta.name, + version=meta.version, title=meta.title, description=meta.description, icons=meta.icons, @@ -312,10 +376,7 @@ def discover_and_import(root: Path) -> DiscoveryResult: for file_path in discover_files(root): try: - module = import_module_from_file(file_path) - except ImportError as e: - result.failed_files[file_path] = str(e) - continue + module = import_module_from_file(file_path, provider_root=root) except Exception as e: result.failed_files[file_path] = str(e) continue diff --git a/src/fastmcp/server/providers/local_provider/decorators/prompts.py b/src/fastmcp/server/providers/local_provider/decorators/prompts.py index d36c7d2f4..583aed563 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/prompts.py +++ b/src/fastmcp/server/providers/local_provider/decorators/prompts.py @@ -15,8 +15,8 @@ import mcp.types from mcp.types import AnyFunction import fastmcp +from fastmcp.prompts.base import Prompt from fastmcp.prompts.function_prompt import FunctionPrompt -from fastmcp.prompts.prompt import Prompt from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig @@ -224,7 +224,7 @@ class PromptDecoratorMixin: enabled=enabled, ) target = fn.__func__ if hasattr(fn, "__func__") else fn - target.__fastmcp__ = metadata # type: ignore[attr-defined] + target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] self.add_prompt(fn) return fn diff --git a/src/fastmcp/server/providers/local_provider/decorators/resources.py b/src/fastmcp/server/providers/local_provider/decorators/resources.py index 80a3e9a5c..41043a461 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/resources.py +++ b/src/fastmcp/server/providers/local_provider/decorators/resources.py @@ -14,8 +14,8 @@ import mcp.types from mcp.types import Annotations, AnyFunction import fastmcp +from fastmcp.resources.base import Resource from fastmcp.resources.function_resource import resource as standalone_resource -from fastmcp.resources.resource import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig @@ -235,7 +235,7 @@ class ResourceDecoratorMixin: enabled=enabled, ) target = fn.__func__ if hasattr(fn, "__func__") else fn - target.__fastmcp__ = metadata # type: ignore[attr-defined] + target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] self.add_resource(fn) return fn diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py index 796be4224..e79cc2c65 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py @@ -27,10 +27,11 @@ import mcp.types from mcp.types import AnyFunction, ToolAnnotations import fastmcp +from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig +from fastmcp.tools.base import Tool from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool import Tool from fastmcp.utilities.types import NotSet, NotSetT try: @@ -43,7 +44,7 @@ except ImportError: if TYPE_CHECKING: from fastmcp.server.providers.local_provider import LocalProvider - from fastmcp.tools.tool import ToolResultSerializerType + from fastmcp.tools.base import ToolResultSerializerType F = TypeVar("F", bound=Callable[..., Any]) @@ -76,13 +77,13 @@ def _ensure_prefab_renderer(provider: LocalProvider) -> None: """Lazily register the shared prefab renderer as a ui:// resource.""" from prefab_ui.renderer import get_renderer_csp, get_renderer_html - from fastmcp.resources.types import TextResource - from fastmcp.server.apps import ( + from fastmcp.apps.config import ( UI_MIME_TYPE, AppConfig, ResourceCSP, app_config_to_meta_dict, ) + from fastmcp.resources.types import TextResource renderer_key = f"resource:{PREFAB_RENDERER_URI}@" if renderer_key in provider._components: @@ -96,7 +97,7 @@ def _ensure_prefab_renderer(provider: LocalProvider) -> None: ) ) resource = TextResource( - uri=PREFAB_RENDERER_URI, # type: ignore[arg-type] # AnyUrl accepts ui:// scheme at runtime + uri=PREFAB_RENDERER_URI, # type: ignore[arg-type] # AnyUrl accepts ui:// scheme at runtime # ty:ignore[invalid-argument-type] name="Prefab Renderer", text=get_renderer_html(), mime_type=UI_MIME_TYPE, @@ -109,7 +110,7 @@ def _expand_prefab_ui_meta(tool: Tool) -> None: """Expand meta["ui"] = True into the full AppConfig dict for a prefab tool.""" from prefab_ui.renderer import get_renderer_csp - from fastmcp.server.apps import AppConfig, ResourceCSP, app_config_to_meta_dict + from fastmcp.apps.config import AppConfig, ResourceCSP, app_config_to_meta_dict csp = get_renderer_csp() app_config = AppConfig( @@ -140,7 +141,10 @@ def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None: # Inference: return type is a prefab type, auto-wire _ensure_prefab_renderer(provider) _expand_prefab_ui_meta(tool) - # If ui is a dict, it's already manually configured — leave it alone + elif isinstance(ui, dict) and ui.get("resourceUri") == PREFAB_RENDERER_URI: + # PrefabAppConfig or manual config pointing to the Prefab renderer — + # ensure the renderer resource is registered (CSP already set by caller) + _ensure_prefab_renderer(provider) class ToolDecoratorMixin: @@ -169,7 +173,7 @@ class ToolDecoratorMixin: # Merge ToolMeta.app into the meta dict tool_meta = fmeta.meta if fmeta.app is not None: - from fastmcp.server.apps import app_config_to_meta_dict + from fastmcp.apps.config import app_config_to_meta_dict tool_meta = dict(tool_meta) if tool_meta else {} if fmeta.app is True: @@ -319,7 +323,7 @@ class ToolDecoratorMixin: "The `serializer` parameter is deprecated. " "Return ToolResult from your tools for full control over serialization. " "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) if isinstance(annotations, dict): @@ -400,7 +404,7 @@ class ToolDecoratorMixin: enabled=enabled, ) target = fn.__func__ if hasattr(fn, "__func__") else fn - target.__fastmcp__ = metadata # type: ignore[attr-defined] + target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] tool_obj = self.add_tool(fn) return fn diff --git a/src/fastmcp/server/providers/local_provider/local_provider.py b/src/fastmcp/server/providers/local_provider/local_provider.py index a1b8fe584..675ff0e63 100644 --- a/src/fastmcp/server/providers/local_provider/local_provider.py +++ b/src/fastmcp/server/providers/local_provider/local_provider.py @@ -27,8 +27,8 @@ from __future__ import annotations from collections.abc import Sequence from typing import Literal, TypeVar -from fastmcp.prompts.prompt import Prompt -from fastmcp.resources.resource import Resource +from fastmcp.prompts.base import Prompt +from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers.base import Provider from fastmcp.server.providers.local_provider.decorators import ( @@ -36,7 +36,7 @@ from fastmcp.server.providers.local_provider.decorators import ( ResourceDecoratorMixin, ToolDecoratorMixin, ) -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.versions import VersionSpec, version_sort_key @@ -191,7 +191,7 @@ class LocalProvider( elif self._on_duplicate == "warn": logger.warning(f"Component already exists: {component.key}") elif self._on_duplicate == "ignore": - return existing # type: ignore[return-value] + return existing # type: ignore[return-value] # ty:ignore[invalid-return-type] # "replace" and "warn" fall through to add # Check for versioned/unversioned mixing before adding @@ -366,7 +366,7 @@ class LocalProvider( matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] async def _list_resources(self) -> Sequence[Resource]: """Return all resources.""" @@ -390,7 +390,7 @@ class LocalProvider( matching = [r for r in matching if version.matches(r.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """Return all resource templates.""" @@ -416,7 +416,7 @@ class LocalProvider( matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] async def _list_prompts(self) -> Sequence[Prompt]: """Return all prompts.""" @@ -440,7 +440,7 @@ class LocalProvider( matching = [p for p in matching if version.matches(p.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] # ========================================================================= # Task registration diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index 1f52033fa..5d8cee1f4 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -13,6 +13,7 @@ from mcp.types import ToolAnnotations from pydantic.networks import AnyUrl import fastmcp +from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.resources import ( Resource, ResourceContent, @@ -21,7 +22,7 @@ from fastmcp.resources import ( ) from fastmcp.server.dependencies import get_http_headers from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.logging import get_logger from fastmcp.utilities.openapi import HTTPRoute from fastmcp.utilities.openapi.director import RequestDirector @@ -29,6 +30,25 @@ from fastmcp.utilities.openapi.director import RequestDirector if TYPE_CHECKING: from fastmcp.server import Context +_SAFE_HEADERS = frozenset( + { + "accept", + "accept-encoding", + "accept-language", + "cache-control", + "connection", + "content-length", + "content-type", + "host", + "user-agent", + } +) + + +def _redact_headers(headers: httpx.Headers) -> dict[str, str]: + return {k: v if k.lower() in _SAFE_HEADERS else "***" for k, v in headers.items()} + + __all__ = [ "OpenAPIResource", "OpenAPIResourceTemplate", @@ -138,7 +158,7 @@ class OpenAPITool(Tool): "The `serializer` parameter is deprecated. " "Return ToolResult from your tools for full control over serialization. " "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) super().__init__( @@ -183,7 +203,9 @@ class OpenAPITool(Tool): # Send the request and process the response. try: - logger.debug(f"run - sending request; headers: {request.headers}") + logger.debug( + f"run - sending request; headers: {_redact_headers(request.headers)}" + ) response = await self._client.send(request) response.raise_for_status() diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py index 1e64d88c9..bc826d1df 100644 --- a/src/fastmcp/server/providers/openapi/provider.py +++ b/src/fastmcp/server/providers/openapi/provider.py @@ -28,7 +28,7 @@ from fastmcp.server.providers.openapi.routing import ( RouteMapFn, _determine_route_type, ) -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.openapi import ( @@ -422,7 +422,7 @@ class OpenAPIProvider(Provider): matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] async def _list_prompts(self) -> Sequence[Prompt]: """Return empty list - OpenAPI doesn't create prompts.""" diff --git a/src/fastmcp/server/providers/proxy.py b/src/fastmcp/server/providers/proxy.py index 5d2ea562f..f2df251cf 100644 --- a/src/fastmcp/server/providers/proxy.py +++ b/src/fastmcp/server/providers/proxy.py @@ -9,6 +9,7 @@ from __future__ import annotations import base64 import inspect +import time from collections.abc import Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, cast from urllib.parse import quote @@ -36,17 +37,18 @@ from fastmcp.client.transports import ClientTransportT from fastmcp.exceptions import ResourceError, ToolError from fastmcp.mcp_config import MCPConfig from fastmcp.prompts import Message, Prompt, PromptResult -from fastmcp.prompts.prompt import PromptArgument +from fastmcp.prompts.base import PromptArgument from fastmcp.resources import Resource, ResourceTemplate -from fastmcp.resources.resource import ResourceContent, ResourceResult +from fastmcp.resources.base import ResourceContent, ResourceResult from fastmcp.server.context import Context from fastmcp.server.dependencies import get_context from fastmcp.server.providers.base import Provider from fastmcp.server.server import FastMCP from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.components import FastMCPComponent, get_fastmcp_metadata from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.versions import VersionSpec, version_sort_key if TYPE_CHECKING: from pathlib import Path @@ -127,7 +129,7 @@ class ProxyTool(Tool): # request. Stash the current RequestContext in the shared # ref so handlers can restore it before forwarding. if isinstance(client, StatefulProxyClient): - cast(list[Any], client._proxy_rc_ref)[0] = ( + client._proxy_rc_ref[0] = ( ctx.request_context, ctx._fastmcp, # weakref to FastMCP, not the Context ) @@ -306,7 +308,7 @@ class ProxyTemplate(ResourceTemplate): @classmethod def from_mcp_template( # type: ignore[override] cls, client_factory: ClientFactoryT, mcp_template: mcp.types.ResourceTemplate - ) -> ProxyTemplate: + ) -> ProxyTemplate: # ty:ignore[invalid-method-override] """Factory method to create a ProxyTemplate from a raw MCP template schema.""" return cls( @@ -443,7 +445,7 @@ class ProxyPrompt(Prompt): task_config=TaskConfig(mode="forbidden"), ) - async def render(self, arguments: dict[str, Any]) -> PromptResult: # type: ignore[override] + async def render(self, arguments: dict[str, Any]) -> PromptResult: # type: ignore[override] # ty:ignore[invalid-method-override] """Render the prompt by making a call through the client.""" backend_name = self._backend_name or self.name with client_span( @@ -477,6 +479,22 @@ class ProxyPrompt(Prompt): # ----------------------------------------------------------------------------- +class _CacheEntry: + """A cached sequence of components with a monotonic timestamp.""" + + __slots__ = ("items", "timestamp") + + def __init__(self, items: Sequence[Any], timestamp: float): + self.items = items + self.timestamp = timestamp + + def is_fresh(self, ttl: float) -> bool: + return (time.monotonic() - self.timestamp) < ttl + + +_DEFAULT_CACHE_TTL: float = 300.0 + + class ProxyProvider(Provider): """Provider that proxies to a remote MCP server via a client factory. @@ -486,6 +504,16 @@ class ProxyProvider(Provider): All components returned by this provider have task_config.mode="forbidden" because tasks cannot be executed through a proxy. + Component lists (tools, resources, templates, prompts) are cached so that + individual lookups (e.g. during ``call_tool``) can resolve from the cache + instead of opening a new backend connection. The cache stores the + backend's raw component metadata and is shared across all sessions; + per-session visibility and auth filtering are applied after cache lookup + by the server layer. The cache is refreshed whenever a ``list_*`` call + is made, and entries expire after ``cache_ttl`` seconds (default 300). + Set ``cache_ttl=0`` to disable caching. Disabling is recommended for + backends whose component lists change dynamically. + Example: ```python from fastmcp import FastMCP @@ -505,6 +533,7 @@ class ProxyProvider(Provider): def __init__( self, client_factory: ClientFactoryT, + cache_ttl: float | None = None, ): """Initialize a ProxyProvider. @@ -512,9 +541,17 @@ class ProxyProvider(Provider): client_factory: A callable that returns a Client instance when called. This gives you full control over session creation and reuse. Can be either a synchronous or asynchronous function. + cache_ttl: How long (in seconds) to cache component lists for + individual lookups. Defaults to 300. Set to 0 to + disable caching. """ super().__init__() self.client_factory = client_factory + self._cache_ttl = cache_ttl if cache_ttl is not None else _DEFAULT_CACHE_TTL + self._tools_cache: _CacheEntry[Tool] | None = None + self._resources_cache: _CacheEntry[Resource] | None = None + self._templates_cache: _CacheEntry[ResourceTemplate] | None = None + self._prompts_cache: _CacheEntry[Prompt] | None = None async def _get_client(self) -> Client: """Gets a client instance by calling the sync or async factory.""" @@ -533,13 +570,31 @@ class ProxyProvider(Provider): client = await self._get_client() async with client: mcp_tools = await client.list_tools() - return [ + tools = [ ProxyTool.from_mcp_tool(self.client_factory, t) for t in mcp_tools ] except McpError as e: if e.error.code == METHOD_NOT_FOUND: - return [] - raise + tools = [] + else: + raise + self._tools_cache = _CacheEntry(tools, time.monotonic()) + return tools + + async def _get_tool( + self, name: str, version: VersionSpec | None = None + ) -> Tool | None: + 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 + matching = [t for t in cache.items if t.name == name] + if version: + matching = [t for t in matching if version.matches(t.version)] + if not matching: + return None + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] # ------------------------------------------------------------------------- # Resource methods @@ -551,14 +606,32 @@ class ProxyProvider(Provider): client = await self._get_client() async with client: mcp_resources = await client.list_resources() - return [ + resources = [ ProxyResource.from_mcp_resource(self.client_factory, r) for r in mcp_resources ] except McpError as e: if e.error.code == METHOD_NOT_FOUND: - return [] - raise + resources = [] + else: + raise + self._resources_cache = _CacheEntry(resources, time.monotonic()) + return resources + + async def _get_resource( + self, uri: str, version: VersionSpec | None = None + ) -> Resource | None: + cache = self._resources_cache + if cache is None or not cache.is_fresh(self._cache_ttl): + await self._list_resources() + cache = self._resources_cache + assert cache is not None + matching = [r for r in cache.items if str(r.uri) == uri] + if version: + matching = [r for r in matching if version.matches(r.version)] + if not matching: + return None + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] # ------------------------------------------------------------------------- # Resource template methods @@ -570,14 +643,32 @@ class ProxyProvider(Provider): client = await self._get_client() async with client: mcp_templates = await client.list_resource_templates() - return [ + templates = [ ProxyTemplate.from_mcp_template(self.client_factory, t) for t in mcp_templates ] except McpError as e: if e.error.code == METHOD_NOT_FOUND: - return [] - raise + templates = [] + else: + raise + self._templates_cache = _CacheEntry(templates, time.monotonic()) + return templates + + async def _get_resource_template( + self, uri: str, version: VersionSpec | None = None + ) -> ResourceTemplate | None: + cache = self._templates_cache + if cache is None or not cache.is_fresh(self._cache_ttl): + await self._list_resource_templates() + cache = self._templates_cache + assert cache is not None + matching = [t for t in cache.items if t.matches(uri) is not None] + if version: + matching = [t for t in matching if version.matches(t.version)] + if not matching: + return None + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] # ------------------------------------------------------------------------- # Prompt methods @@ -589,14 +680,32 @@ class ProxyProvider(Provider): client = await self._get_client() async with client: mcp_prompts = await client.list_prompts() - return [ + prompts = [ ProxyPrompt.from_mcp_prompt(self.client_factory, p) for p in mcp_prompts ] except McpError as e: if e.error.code == METHOD_NOT_FOUND: - return [] - raise + prompts = [] + else: + raise + self._prompts_cache = _CacheEntry(prompts, time.monotonic()) + return prompts + + async def _get_prompt( + self, name: str, version: VersionSpec | None = None + ) -> Prompt | None: + cache = self._prompts_cache + if cache is None or not cache.is_fresh(self._cache_ttl): + await self._list_prompts() + cache = self._prompts_cache + assert cache is not None + matching = [p for p in cache.items if p.name == name] + if version: + matching = [p for p in matching if version.matches(p.version)] + if not matching: + return None + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] # ------------------------------------------------------------------------- # Task methods @@ -642,6 +751,17 @@ def _create_client_factory( """ if isinstance(target, Client): client = target + if client.is_connected() and type(client) is ProxyClient: + logger.info( + "Proxy detected connected ProxyClient - creating fresh sessions for each " + "request to avoid request context leakage." + ) + + def fresh_client_factory() -> Client: + return client.new() + + return fresh_client_factory + if client.is_connected(): logger.info( "Proxy detected connected client - reusing existing session for all requests. " @@ -652,12 +772,11 @@ def _create_client_factory( return client return reuse_client_factory - else: - def fresh_client_factory() -> Client: - return client.new() + def fresh_client_factory() -> Client: + return client.new() - return fresh_client_factory + return fresh_client_factory else: # target is not a Client, so it's compatible with ProxyClient.__init__ base_client = ProxyClient(cast(Any, target)) @@ -923,7 +1042,7 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]): super().__init__(*args, **kwargs) self._caches: dict[ServerSession, Client[ClientTransportT]] = {} - async def __aexit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[override] + async def __aexit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[override] # ty:ignore[invalid-method-override] """The stateful proxy client will be forced disconnected when the session is exited. So we do nothing here. diff --git a/src/fastmcp/server/providers/skills/_common.py b/src/fastmcp/server/providers/skills/_common.py index 95e5b2db0..d0e1177a5 100644 --- a/src/fastmcp/server/providers/skills/_common.py +++ b/src/fastmcp/server/providers/skills/_common.py @@ -86,16 +86,22 @@ def compute_file_hash(path: Path) -> str: def scan_skill_files(skill_dir: Path) -> list[SkillFileInfo]: """Scan a skill directory for all files.""" files = [] + resolved_skill_dir = skill_dir.resolve() + # Sort for deterministic ordering across platforms for file_path in sorted(skill_dir.rglob("*")): if file_path.is_file(): + resolved_file_path = file_path.resolve() + if not resolved_file_path.is_relative_to(resolved_skill_dir): + continue + rel_path = file_path.relative_to(skill_dir) files.append( SkillFileInfo( # Use POSIX paths for cross-platform URI consistency path=rel_path.as_posix(), - size=file_path.stat().st_size, - hash=compute_file_hash(file_path), + size=resolved_file_path.stat().st_size, + hash=compute_file_hash(resolved_file_path), ) ) return files diff --git a/src/fastmcp/server/providers/skills/directory_provider.py b/src/fastmcp/server/providers/skills/directory_provider.py index 27f7c1e4c..c390b42f5 100644 --- a/src/fastmcp/server/providers/skills/directory_provider.py +++ b/src/fastmcp/server/providers/skills/directory_provider.py @@ -6,7 +6,7 @@ from collections.abc import Sequence from pathlib import Path from typing import Literal -from fastmcp.resources.resource import Resource +from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers.aggregate import AggregateProvider from fastmcp.server.providers.skills.skill_provider import SkillProvider diff --git a/src/fastmcp/server/providers/skills/skill_provider.py b/src/fastmcp/server/providers/skills/skill_provider.py index 86bcba5e3..8e8d2cf4b 100644 --- a/src/fastmcp/server/providers/skills/skill_provider.py +++ b/src/fastmcp/server/providers/skills/skill_provider.py @@ -10,7 +10,7 @@ from typing import Any, Literal, cast from pydantic import AnyUrl -from fastmcp.resources.resource import Resource, ResourceResult +from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers.base import Provider from fastmcp.server.providers.skills._common import ( @@ -103,7 +103,7 @@ class SkillFileTemplate(ResourceTemplate): uri: str, params: dict[str, Any], task_meta: Any = None, - ) -> ResourceResult: + ) -> ResourceResult: # ty:ignore[invalid-method-override] """Server entry point - read file directly without creating ephemeral resource. Note: task_meta is ignored - this template doesn't support background tasks. diff --git a/src/fastmcp/server/providers/wrapped_provider.py b/src/fastmcp/server/providers/wrapped_provider.py index 4f5c0df92..3ce097fff 100644 --- a/src/fastmcp/server/providers/wrapped_provider.py +++ b/src/fastmcp/server/providers/wrapped_provider.py @@ -14,11 +14,11 @@ from fastmcp.server.providers.base import Provider from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: - from fastmcp.prompts.prompt import Prompt - from fastmcp.resources.resource import Resource + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.transforms import Transform - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent @@ -63,6 +63,10 @@ class _WrappedProvider(Provider): """Delegate to inner's get_tool (includes inner's transforms).""" return await self._inner.get_tool(name, version) + async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: + """Delegate to inner, bypassing this wrapper's transforms.""" + return await self._inner.get_app_tool(app_name, tool_name) + async def _list_resources(self) -> Sequence[Resource]: """Delegate to inner's list_resources (includes inner's transforms).""" return await self._inner.list_resources() @@ -96,10 +100,10 @@ class _WrappedProvider(Provider): async def get_tasks(self) -> Sequence[FastMCPComponent]: """Delegate to inner's get_tasks and apply wrapper's transforms.""" # Import here to avoid circular imports - from fastmcp.prompts.prompt import Prompt - from fastmcp.resources.resource import Resource + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool # Get tasks from inner (already has inner's transforms) components = list(await self._inner.get_tasks()) diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 45190c0f8..fccfd5041 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -9,9 +9,11 @@ from __future__ import annotations import warnings +from fastmcp.exceptions import FastMCPDeprecationWarning + warnings.warn( "fastmcp.server.proxy is deprecated. Use fastmcp.server.providers.proxy instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index 1781eb651..7f9354bbb 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -10,9 +10,12 @@ 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 import ToolResult from fastmcp.tools.tool_transform import TransformedTool from fastmcp.utilities.types import FastMCPBaseModel @@ -151,6 +154,24 @@ class SamplingTool(FastMCPBaseModel): # 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): diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 0dc47f143..789bc42ba 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging import re import secrets import warnings @@ -41,8 +42,14 @@ from typing_extensions import Self import fastmcp import fastmcp.server +from fastmcp.apps.config import ( + AppConfig, + app_config_to_meta_dict, + resolve_ui_mime_type, +) from fastmcp.exceptions import ( AuthorizationError, + FastMCPDeprecationWarning, FastMCPError, NotFoundError, PromptError, @@ -52,15 +59,10 @@ from fastmcp.exceptions import ( ) from fastmcp.mcp_config import MCPConfig from fastmcp.prompts import Prompt +from fastmcp.prompts.base import PromptResult from fastmcp.prompts.function_prompt import FunctionPrompt -from fastmcp.prompts.prompt import PromptResult -from fastmcp.resources.resource import Resource, ResourceResult +from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.apps import ( - AppConfig, - app_config_to_meta_dict, - resolve_ui_mime_type, -) from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks from fastmcp.server.lifespan import Lifespan from fastmcp.server.low_level import LowLevelServer @@ -76,14 +78,15 @@ from fastmcp.server.transforms import ( ) from fastmcp.server.transforms.visibility import apply_session_transforms, is_enabled from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting +from fastmcp.tools.base import Tool, ToolResult from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool import Tool, ToolResult from fastmcp.tools.tool_transform import ToolTransformConfig -from fastmcp.utilities.components import FastMCPComponent +from fastmcp.utilities.components import FastMCPComponent, _coerce_version from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import FastMCPBaseModel, NotSet, NotSetT from fastmcp.utilities.versions import ( VersionSpec, + version_sort_key, ) if TYPE_CHECKING: @@ -98,6 +101,19 @@ if TYPE_CHECKING: logger = get_logger(__name__) + +# 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. +class _SuppressUnlistedToolWarning(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return "not listed, no validation" not in record.getMessage() + + +logging.getLogger("mcp.server.lowlevel.server").addFilter( + _SuppressUnlistedToolWarning() +) + F = TypeVar("F", bound=Callable[..., Any]) DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] @@ -166,6 +182,32 @@ def _get_auth_context() -> tuple[bool, Any]: return (False, get_access_token()) +def _is_model_visible(tool: Tool) -> bool: + """Check whether a tool should be visible to the model. + + Tools registered via ``@app.tool()`` (without ``model=True``) have + ``meta["ui"]["visibility"] == ["app"]`` — they are callable by app UIs + but should not appear in the model's tool list. + + Returns True (visible) when: + - The tool has no ``meta.ui.visibility`` (normal tools). + - ``"model"`` is in the visibility list (e.g. ``["model"]`` or ``["app", "model"]``). + + Returns False when the visibility list exists and does not contain ``"model"`` + (e.g. ``["app"]``). + """ + meta = tool.meta + if not meta: + return True + ui = meta.get("ui") + if not isinstance(ui, dict): + return True + visibility = ui.get("visibility") + if not isinstance(visibility, list): + return True + return "model" in visibility + + @asynccontextmanager async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]: """Default lifespan context manager that does nothing. @@ -189,7 +231,7 @@ def _lifespan_proxy( low_level_server: LowLevelServer[LifespanResultT], ) -> AsyncIterator[LifespanResultT]: if fastmcp_server._lifespan is default_lifespan: - yield {} + yield {} # ty:ignore[invalid-yield] return if not fastmcp_server._lifespan_result_set: @@ -198,7 +240,7 @@ def _lifespan_proxy( + " Are you running the server in a way that supports lifespans? If so, please file an issue at https://github.com/PrefectHQ/fastmcp/issues." ) - yield fastmcp_server._lifespan_result + yield fastmcp_server._lifespan_result # ty:ignore[invalid-yield] return wrap @@ -221,7 +263,7 @@ class FastMCP( name: str | None = None, instructions: str | None = None, *, - version: str | None = None, + version: str | int | float | None = None, website_url: str | None = None, icons: list[mcp.types.Icon] | None = None, auth: AuthProvider | None = None, @@ -239,6 +281,7 @@ class FastMCP( 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, **kwargs: Any, ): _check_removed_kwargs(kwargs) @@ -300,6 +343,8 @@ class FastMCP( self._lifespan = cast(LifespanCallable[LifespanResultT], default_lifespan) self._lifespan_result: LifespanResultT | None = None self._lifespan_result_set: bool = False + self._lifespan_ref_count: int = 0 + self._lifespan_lock: asyncio.Lock = asyncio.Lock() self._started: asyncio.Event = asyncio.Event() # Generate random ID if no name provided @@ -308,7 +353,7 @@ class FastMCP( ]( fastmcp=self, name=name or self.generate_name(), - version=version or fastmcp.__version__, + version=_coerce_version(version) or fastmcp.__version__, instructions=instructions, website_url=website_url, icons=icons, @@ -329,6 +374,12 @@ class FastMCP( else fastmcp.settings.strict_input_validation ) + self.client_log_level: mcp.types.LoggingLevel | None = ( + client_log_level + if client_log_level is not None + else fastmcp.settings.client_log_level + ) + self.middleware: list[Middleware] = list(middleware or []) if dereference_schemas: @@ -485,7 +536,7 @@ class FastMCP( warnings.warn( "add_tool_transformation is deprecated. Use " "server.add_transform(ToolTransform({tool_name: config})) instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) self.add_transform(ToolTransform({tool_name: transformation})) @@ -501,7 +552,7 @@ class FastMCP( "remove_tool_transformation is deprecated and has no effect. " "Transforms are immutable once added. Use server.disable(keys=[...]) " "to hide tools instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) @@ -527,9 +578,10 @@ class FastMCP( ) # Get all tools, apply session transforms, then filter enabled + # and model-visible (app-only tools are hidden from the model). tools = list(await super().list_tools()) tools = await apply_session_transforms(tools) - tools = [t for t in tools if is_enabled(t)] + tools = [t for t in tools if is_enabled(t) and _is_model_visible(t)] skip_auth, token = _get_auth_context() authorized: list[Tool] = [] @@ -584,6 +636,9 @@ class FastMCP( transforms (including session-level) have been applied. This ensures session transforms can override provider-level disables. + When the highest version is disabled and no explicit version was + requested, falls back to the next-highest enabled version. + Args: name: The tool name. version: Version filter (None returns highest version). @@ -597,9 +652,34 @@ class FastMCP( # Apply session transforms to single item tools = await apply_session_transforms([tool]) - if not tools or not is_enabled(tools[0]): + if tools and is_enabled(tools[0]) and _is_model_visible(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. + if version is not None: return None - return tools[0] + + 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 _is_model_visible(t)] + + skip_auth, token = _get_auth_context() + authorized: list[Tool] = [] + for t in enabled: + if not skip_auth and t.auth is not None: + ctx = AuthContext(token=token, component=t) + try: + if not await run_auth_checks(t.auth, ctx): + continue + except AuthorizationError: + continue + authorized.append(t) + + if not authorized: + return None + return cast(Tool, max(authorized, key=version_sort_key)) async def list_resources( self, *, run_middleware: bool = True @@ -681,6 +761,9 @@ class FastMCP( Overrides Provider.get_resource() to add visibility filtering after all transforms (including session-level) have been applied. + When the highest version is disabled and no explicit version was + requested, falls back to the next-highest enabled version. + Args: uri: The resource URI. version: Version filter (None returns highest version). @@ -694,9 +777,31 @@ class FastMCP( # Apply session transforms to single item resources = await apply_session_transforms([resource]) - if not resources or not is_enabled(resources[0]): + if resources and is_enabled(resources[0]): + return resources[0] + + if version is not None: return None - return resources[0] + + all_resources = [r for r in await super().list_resources() if str(r.uri) == uri] + all_resources = list(await apply_session_transforms(all_resources)) + enabled = [r for r in all_resources if is_enabled(r)] + + skip_auth, token = _get_auth_context() + authorized: list[Resource] = [] + for r in enabled: + if not skip_auth and r.auth is not None: + ctx = AuthContext(token=token, component=r) + try: + if not await run_auth_checks(r.auth, ctx): + continue + except AuthorizationError: + continue + authorized.append(r) + + if not authorized: + return None + return cast(Resource, max(authorized, key=version_sort_key)) async def list_resource_templates( self, *, run_middleware: bool = True @@ -780,6 +885,9 @@ class FastMCP( Overrides Provider.get_resource_template() to add visibility filtering after all transforms (including session-level) have been applied. + When the highest version is disabled and no explicit version was + requested, falls back to the next-highest enabled version. + Args: uri: The template URI. version: Version filter (None returns highest version). @@ -793,9 +901,35 @@ class FastMCP( # Apply session transforms to single item templates = await apply_session_transforms([template]) - if not templates or not is_enabled(templates[0]): + if templates and is_enabled(templates[0]): + return templates[0] + + if version is not None: return None - return templates[0] + + all_templates = [ + t + for t in await super().list_resource_templates() + if t.matches(uri) is not None + ] + all_templates = list(await apply_session_transforms(all_templates)) + enabled = [t for t in all_templates if is_enabled(t)] + + skip_auth, token = _get_auth_context() + authorized: list[ResourceTemplate] = [] + for t in enabled: + if not skip_auth and t.auth is not None: + ctx = AuthContext(token=token, component=t) + try: + if not await run_auth_checks(t.auth, ctx): + continue + except AuthorizationError: + continue + authorized.append(t) + + if not authorized: + return None + return cast(ResourceTemplate, max(authorized, key=version_sort_key)) async def list_prompts(self, *, run_middleware: bool = True) -> Sequence[Prompt]: """List all enabled prompts from providers. @@ -875,6 +1009,9 @@ class FastMCP( Overrides Provider.get_prompt() to add visibility filtering after all transforms (including session-level) have been applied. + When the highest version is disabled and no explicit version was + requested, falls back to the next-highest enabled version. + Args: name: The prompt name. version: Version filter (None returns highest version). @@ -888,9 +1025,31 @@ class FastMCP( # Apply session transforms to single item prompts = await apply_session_transforms([prompt]) - if not prompts or not is_enabled(prompts[0]): + if prompts and is_enabled(prompts[0]): + return prompts[0] + + if version is not None: return None - return prompts[0] + + all_prompts = [p for p in await super().list_prompts() if p.name == name] + all_prompts = list(await apply_session_transforms(all_prompts)) + enabled = [p for p in all_prompts if is_enabled(p)] + + skip_auth, token = _get_auth_context() + authorized: list[Prompt] = [] + for p in enabled: + if not skip_auth and p.auth is not None: + ctx = AuthContext(token=token, component=p) + try: + if not await run_auth_checks(p.auth, ctx): + continue + except AuthorizationError: + continue + authorized.append(p) + + if not authorized: + return None + return cast(Prompt, max(authorized, key=version_sort_key)) @overload async def call_tool( @@ -977,7 +1136,23 @@ class FastMCP( with server_span( f"tools/call {name}", "tools/call", self.name, "tool", name ) as span: - tool = await self.get_tool(name, version=version) + # Try normal resolution first. If that fails and the name + # contains "___" (app tool prefix), parse out the app name + # and route via get_app_tool which bypasses transforms. + tool: Tool | None = await self.get_tool(name, version=version) + if tool is None and "___" in name: + app_prefix, _, tool_suffix = name.partition("___") + tool = await self.get_app_tool(app_prefix, tool_suffix) + if tool is not None: + # Auth still applies to app tools + skip_auth, token = _get_auth_context() + if not skip_auth and tool.auth is not None: + try: + ctx = AuthContext(token=token, component=tool) + if not await run_auth_checks(tool.auth, ctx): + raise NotFoundError(f"Unknown tool: {name!r}") + except AuthorizationError: + raise NotFoundError(f"Unknown tool: {name!r}") from None if tool is None: raise NotFoundError(f"Unknown tool: {name!r}") span.set_attributes(tool.get_span_attributes()) @@ -1291,7 +1466,7 @@ class FastMCP( warnings.warn( "remove_tool() is deprecated. Use " "mcp.local_provider.remove_tool(name) instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) try: @@ -1786,7 +1961,7 @@ class FastMCP( if prefix is not None: warnings.warn( "The 'prefix' parameter is deprecated, use 'namespace' instead", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) if namespace is None: @@ -1799,7 +1974,7 @@ class FastMCP( "as_proxy is deprecated and will be removed in a future version. " "Mounted servers now always have their lifespan and middleware invoked. " "To create a proxy server, use create_proxy() explicitly.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) # Still honor the flag for backward compatibility @@ -1868,7 +2043,7 @@ class FastMCP( warnings.warn( "import_server is deprecated, use mount() instead", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) @@ -2060,7 +2235,7 @@ class FastMCP( warnings.warn( "FastMCP.as_proxy() is deprecated. Use create_proxy() from " "fastmcp.server instead: `from fastmcp.server import create_proxy`", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) # Call the module-level create_proxy function directly diff --git a/src/fastmcp/server/tasks/capabilities.py b/src/fastmcp/server/tasks/capabilities.py index 9f7d4b4f9..48c1f3d71 100644 --- a/src/fastmcp/server/tasks/capabilities.py +++ b/src/fastmcp/server/tasks/capabilities.py @@ -36,7 +36,7 @@ def get_task_capabilities() -> ServerTasksCapability | None: cancel=TasksCancelCapability(), requests=ServerTasksRequestsCapability( tools=TasksToolsCapability(call=TasksCallCapability()), - prompts={"get": {}}, # type: ignore[call-arg] # extra_data for forward compat - resources={"read": {}}, # type: ignore[call-arg] # extra_data for forward compat + prompts={"get": {}}, # type: ignore[call-arg] # extra_data for forward compat # ty:ignore[unknown-argument] + resources={"read": {}}, # type: ignore[call-arg] # extra_data for forward compat # ty:ignore[unknown-argument] ), ) diff --git a/src/fastmcp/server/tasks/config.py b/src/fastmcp/server/tasks/config.py index 4956a7667..1d5befa2a 100644 --- a/src/fastmcp/server/tasks/config.py +++ b/src/fastmcp/server/tasks/config.py @@ -6,12 +6,15 @@ handle task-augmented execution as specified in SEP-1686. from __future__ import annotations +import functools import inspect from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta from typing import Any, Literal +from fastmcp.utilities.async_utils import is_coroutine_function + # Task execution modes per SEP-1686 / MCP ToolExecution.taskSupport TaskMode = Literal["forbidden", "optional", "required"] @@ -124,12 +127,16 @@ class TaskConfig: # Unwrap callable classes and staticmethods fn_to_check = fn - if not inspect.isroutine(fn) and callable(fn): + if ( + not inspect.isroutine(fn) + and not isinstance(fn, functools.partial) + and callable(fn) + ): fn_to_check = fn.__call__ if isinstance(fn_to_check, staticmethod): fn_to_check = fn_to_check.__func__ - if not inspect.iscoroutinefunction(fn_to_check): + if not is_coroutine_function(fn_to_check): raise ValueError( f"'{name}' uses a sync function but has task execution enabled. " "Background tasks require async functions." diff --git a/src/fastmcp/server/tasks/elicitation.py b/src/fastmcp/server/tasks/elicitation.py index cb148cfc7..cc6ac2624 100644 --- a/src/fastmcp/server/tasks/elicitation.py +++ b/src/fastmcp/server/tasks/elicitation.py @@ -332,7 +332,7 @@ async def handle_task_input( await redis.lpush( # type: ignore[invalid-await] # redis-py union type (sync/async) docket.key(response_key), json.dumps(response), - ) + ) # ty:ignore[invalid-await] # Set TTL on the response list (in case BLPOP doesn't consume it) await redis.expire(docket.key(response_key), ELICIT_TTL_SECONDS) diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py index 10bf18b6d..051785ddd 100644 --- a/src/fastmcp/server/tasks/handlers.py +++ b/src/fastmcp/server/tasks/handlers.py @@ -5,6 +5,7 @@ Handles queuing tool/prompt/resource executions to Docket as background tasks. from __future__ import annotations +import json import uuid from contextlib import suppress from datetime import datetime, timezone @@ -14,16 +15,22 @@ import mcp.types from mcp.shared.exceptions import McpError from mcp.types import INTERNAL_ERROR, ErrorData -from fastmcp.server.dependencies import _current_docket, get_access_token, get_context +from fastmcp.server.dependencies import ( + _current_docket, + get_access_token, + get_context, + get_http_headers, + register_task_server, +) from fastmcp.server.tasks.config import TaskMeta from fastmcp.server.tasks.keys import build_task_key from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: - from fastmcp.prompts.prompt import Prompt - from fastmcp.resources.resource import Resource + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool logger = get_logger(__name__) @@ -80,6 +87,12 @@ async def submit_to_docket( ) ) + # Register the current server so background workers resolve + # CurrentFastMCP() / ctx.fastmcp to the correct (child) server + # for mounted tasks. At this point ctx.fastmcp is the child because + # we're inside the child's call_tool dispatch. + register_task_server(server_task_id, ctx.fastmcp) + # Build full task key with embedded metadata task_key = build_task_key(session_id, server_task_id, task_type, key) @@ -111,6 +124,10 @@ async def submit_to_docket( access_token_key = docket.key( f"fastmcp:task:{session_id}:{server_task_id}:access_token" ) + http_headers = get_http_headers(include_all=True) + http_headers_key = docket.key( + f"fastmcp:task:{session_id}:{server_task_id}:http_headers" + ) async with docket.redis() as redis: await redis.set(task_meta_key, task_key, ex=ttl_seconds) @@ -122,6 +139,8 @@ async def submit_to_docket( await redis.set( access_token_key, access_token.model_dump_json(), ex=ttl_seconds ) + if http_headers: + await redis.set(http_headers_key, json.dumps(http_headers), ex=ttl_seconds) # Register session for Context access in background workers (SEP-1686) # This enables elicitation/sampling from background tasks via weakref @@ -163,9 +182,9 @@ async def submit_to_docket( # `task_key` is the task result key (e.g., "fastmcp:task:{session}:{task_id}:tool:child_multiply") # Resources don't take arguments; tools/prompts/templates always pass arguments (even if None/empty) if task_type == "resource": - await component.add_to_docket(docket, fn_key=key, task_key=task_key) # type: ignore[call-arg] + await component.add_to_docket(docket, fn_key=key, task_key=task_key) # type: ignore[call-arg] # ty:ignore[missing-argument] else: - await component.add_to_docket(docket, arguments, fn_key=key, task_key=task_key) # type: ignore[call-arg] + await component.add_to_docket(docket, arguments, fn_key=key, task_key=task_key) # type: ignore[call-arg] # ty:ignore[invalid-argument-type, too-many-positional-arguments] # Spawn subscription task to send status notifications (SEP-1686 optional feature) from fastmcp.server.tasks.subscriptions import subscribe_to_task_updates @@ -174,7 +193,7 @@ async def submit_to_docket( if hasattr(ctx.session, "_subscription_task_group"): tg = ctx.session._subscription_task_group if tg: - tg.start_soon( # type: ignore[union-attr] + tg.start_soon( # type: ignore[union-attr] # ty:ignore[unresolved-attribute] subscribe_to_task_updates, server_task_id, task_key, @@ -206,7 +225,7 @@ async def submit_to_docket( await stop_subscriber(session_id) ctx.session._exit_stack.push_async_callback(_cleanup_subscriber) - ctx.session._notification_cleanup_registered = True # type: ignore[attr-defined] + ctx.session._notification_cleanup_registered = True # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] except Exception as e: # Non-fatal: elicitation will still work via polling fallback logger.debug("Failed to start notification subscriber: %s", e) diff --git a/src/fastmcp/server/tasks/notifications.py b/src/fastmcp/server/tasks/notifications.py index 67417bd62..6656bc361 100644 --- a/src/fastmcp/server/tasks/notifications.py +++ b/src/fastmcp/server/tasks/notifications.py @@ -69,7 +69,7 @@ async def push_notification( } ) async with docket.redis() as redis: - await redis.lpush(key, message) # type: ignore[invalid-await] # redis-py union type (sync/async) + await redis.lpush(key, message) # type: ignore[invalid-await] # redis-py union type (sync/async) # ty:ignore[invalid-await] await redis.expire(key, NOTIFICATION_TTL_SECONDS) @@ -135,7 +135,7 @@ async def notification_subscriber_loop( # Re-queue with incremented attempt (back of queue) message["attempt"] = attempt + 1 message["last_error"] = str(send_error) - await redis.lpush(queue_key, json.dumps(message)) # type: ignore[invalid-await] + await redis.lpush(queue_key, json.dumps(message)) # type: ignore[invalid-await] # ty:ignore[invalid-await] logger.debug( "Requeued notification for session %s (attempt %d): %s", session_id, diff --git a/src/fastmcp/server/tasks/requests.py b/src/fastmcp/server/tasks/requests.py index fae63c08d..8743356e5 100644 --- a/src/fastmcp/server/tasks/requests.py +++ b/src/fastmcp/server/tasks/requests.py @@ -25,12 +25,12 @@ from mcp.types import ( import fastmcp.server.context from fastmcp.exceptions import NotFoundError -from fastmcp.prompts.prompt import Prompt -from fastmcp.resources.resource import Resource +from fastmcp.prompts.base import Prompt +from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.tasks.config import DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_MS from fastmcp.server.tasks.keys import parse_task_key -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: @@ -178,7 +178,7 @@ async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskR state_map = DOCKET_TO_MCP_STATE mcp_state: Literal[ "working", "input_required", "completed", "failed", "cancelled" - ] = state_map.get(execution.state, "failed") # type: ignore[assignment] + ] = state_map.get(execution.state, "failed") # type: ignore[assignment] # ty:ignore[invalid-assignment] # Build response (use default ttl since we don't track per-task values) # createdAt is REQUIRED per SEP-1686 final spec (line 430) @@ -303,7 +303,7 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: "io.modelcontextprotocol/related-task": { "taskId": client_task_id, } - }, + }, # ty:ignore[unknown-argument] ) # Parse task key to get component key @@ -347,43 +347,48 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: } } - # Convert based on component type + # Convert based on component type. + # Each branch merges related_task_meta with any existing _meta + # (e.g. fastmcp.wrap_result) rather than overwriting it. if isinstance(component, Tool): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_result() - # Ensure we have a CallToolResult and add metadata if isinstance(mcp_result, mcp.types.CallToolResult): - mcp_result._meta = related_task_meta # type: ignore[attr-defined] + merged = {**(mcp_result.meta or {}), **related_task_meta} + mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] elif isinstance(mcp_result, tuple): content, structured_content = mcp_result mcp_result = mcp.types.CallToolResult( content=content, structuredContent=structured_content, - _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] ) else: mcp_result = mcp.types.CallToolResult( content=mcp_result, - _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] ) return mcp_result elif isinstance(component, Prompt): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_prompt_result() - mcp_result._meta = related_task_meta # type: ignore[attr-defined] + merged = {**(mcp_result.meta or {}), **related_task_meta} + mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] return mcp_result elif isinstance(component, ResourceTemplate): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_result(component.uri_template) - mcp_result._meta = related_task_meta # type: ignore[attr-defined] + merged = {**(mcp_result.meta or {}), **related_task_meta} + mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] return mcp_result elif isinstance(component, Resource): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_result(str(component.uri)) - mcp_result._meta = related_task_meta # type: ignore[attr-defined] + merged = {**(mcp_result.meta or {}), **related_task_meta} + mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] return mcp_result else: diff --git a/src/fastmcp/server/tasks/routing.py b/src/fastmcp/server/tasks/routing.py index ab9e240c8..cb6812a87 100644 --- a/src/fastmcp/server/tasks/routing.py +++ b/src/fastmcp/server/tasks/routing.py @@ -15,10 +15,10 @@ from fastmcp.server.tasks.config import TaskMeta from fastmcp.server.tasks.handlers import submit_to_docket if TYPE_CHECKING: - from fastmcp.prompts.prompt import Prompt - from fastmcp.resources.resource import Resource + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool TaskType = Literal["tool", "resource", "template", "prompt"] diff --git a/src/fastmcp/server/tasks/subscriptions.py b/src/fastmcp/server/tasks/subscriptions.py index 9c6c8d59e..772b82671 100644 --- a/src/fastmcp/server/tasks/subscriptions.py +++ b/src/fastmcp/server/tasks/subscriptions.py @@ -55,17 +55,26 @@ async def subscribe_to_task_updates( return # Subscribe to state and progress events from Docket + terminal_states = { + ExecutionState.COMPLETED, + ExecutionState.FAILED, + ExecutionState.CANCELLED, + } async for event in execution.subscribe(): if event["type"] == "state": + state = ExecutionState(event["state"]) # Send notifications/tasks/status when state changes await _send_status_notification( session=session, task_id=task_id, task_key=task_key, docket=docket, - state=ExecutionState(event["state"]), + state=state, poll_interval_ms=poll_interval_ms, ) + # Stop subscribing once the task reaches a terminal state + if state in terminal_states: + break elif event["type"] == "progress": # Send notification when progress message changes await _send_progress_notification( @@ -148,7 +157,7 @@ async def _send_status_notification( # Send notification (don't let failures break the subscription) with suppress(Exception): - await session.send_notification(notification) # type: ignore[arg-type] + await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] async def _send_progress_notification( @@ -210,4 +219,4 @@ async def _send_progress_notification( ) with suppress(Exception): - await session.send_notification(notification) # type: ignore[arg-type] + await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] diff --git a/src/fastmcp/server/transforms/__init__.py b/src/fastmcp/server/transforms/__init__.py index cdda16d82..411a2e0f8 100644 --- a/src/fastmcp/server/transforms/__init__.py +++ b/src/fastmcp/server/transforms/__init__.py @@ -26,10 +26,10 @@ from typing import TYPE_CHECKING, Protocol from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: - from fastmcp.prompts.prompt import Prompt - from fastmcp.resources.resource import Resource + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool # Get methods use Protocol to express keyword-only version parameter diff --git a/src/fastmcp/server/transforms/catalog.py b/src/fastmcp/server/transforms/catalog.py index 748e12645..936fcd9b3 100644 --- a/src/fastmcp/server/transforms/catalog.py +++ b/src/fastmcp/server/transforms/catalog.py @@ -53,11 +53,11 @@ from fastmcp.server.transforms import Transform from fastmcp.utilities.versions import dedupe_with_versions if TYPE_CHECKING: - from fastmcp.prompts.prompt import Prompt - from fastmcp.resources.resource import Resource + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.context import Context - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool _instance_counter = itertools.count() diff --git a/src/fastmcp/server/transforms/namespace.py b/src/fastmcp/server/transforms/namespace.py index 152d493e5..f1a219ac7 100644 --- a/src/fastmcp/server/transforms/namespace.py +++ b/src/fastmcp/server/transforms/namespace.py @@ -16,10 +16,10 @@ from fastmcp.server.transforms import ( from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: - from fastmcp.prompts.prompt import Prompt - from fastmcp.resources.resource import Resource + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool # Pattern for matching URIs: protocol://path _URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$") diff --git a/src/fastmcp/server/transforms/prompts_as_tools.py b/src/fastmcp/server/transforms/prompts_as_tools.py index 83ef30815..078b250d0 100644 --- a/src/fastmcp/server/transforms/prompts_as_tools.py +++ b/src/fastmcp/server/transforms/prompts_as_tools.py @@ -3,6 +3,10 @@ This transform generates tools for listing and getting prompts, enabling clients that only support tools to access prompt functionality. +The generated tools route through `ctx.fastmcp` at runtime, so all server +middleware (auth, visibility, rate limiting, etc.) applies to prompt +operations exactly as it would for direct `prompts/get` calls. + Example: ```python from fastmcp import FastMCP @@ -22,26 +26,28 @@ from typing import TYPE_CHECKING, Annotated, Any from mcp.types import TextContent +from fastmcp.server.dependencies import get_context from fastmcp.server.transforms import GetToolNext, Transform -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.server.providers.base import Provider -# Note: FastMCP imported inside tools to avoid circular import - class PromptsAsTools(Transform): """Transform that adds tools for listing and getting prompts. Generates two tools: - - `list_prompts`: Lists all prompts from the provider + - `list_prompts`: Lists all prompts - `get_prompt`: Gets a specific prompt with optional arguments - The transform captures a provider reference at construction and queries it - for prompts when the generated tools are called. When used with FastMCP, - the provider's auth and visibility filtering is automatically applied. + The generated tools route through the server at runtime, so auth, + middleware, and visibility apply automatically. + + This transform should be applied to a FastMCP server instance, not + a raw Provider, because the generated tools need the server's + middleware chain for auth and visibility filtering. Example: ```python @@ -52,12 +58,15 @@ class PromptsAsTools(Transform): """ def __init__(self, provider: Provider) -> None: - """Initialize the transform with a provider reference. + from fastmcp.server.server import FastMCP - Args: - provider: The provider to query for prompts. Typically this is - the same FastMCP server the transform is added to. - """ + if not isinstance(provider, FastMCP): + raise TypeError( + "PromptsAsTools requires a FastMCP server instance, not a" + f" {type(provider).__name__}. The generated tools route through" + " the server's middleware chain at runtime for auth and" + " visibility. Pass your FastMCP server: PromptsAsTools(mcp)" + ) self._provider = provider def __repr__(self) -> str: @@ -75,18 +84,14 @@ class PromptsAsTools(Transform): self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name, including generated prompt tools.""" - # Check if it's one of our generated tools if name == "list_prompts": return self._make_list_prompts_tool() if name == "get_prompt": return self._make_get_prompt_tool() - - # Otherwise delegate to downstream return await call_next(name, version=version) def _make_list_prompts_tool(self) -> Tool: """Create the list_prompts tool.""" - provider = self._provider async def list_prompts() -> str: """List all available prompts. @@ -94,7 +99,8 @@ class PromptsAsTools(Transform): Returns JSON with prompt metadata including name, description, and optional arguments. """ - prompts = await provider.list_prompts() + ctx = get_context() + prompts = await ctx.fastmcp.list_prompts() result: list[dict[str, Any]] = [] for p in prompts: @@ -119,7 +125,6 @@ class PromptsAsTools(Transform): def _make_get_prompt_tool(self) -> Tool: """Create the get_prompt tool.""" - provider = self._provider async def get_prompt( name: Annotated[str, "The name of the prompt to get"], @@ -131,21 +136,11 @@ class PromptsAsTools(Transform): """Get a prompt by name with optional arguments. Returns the rendered prompt as JSON with a messages array. - Arguments should be provided as a dict mapping argument names to values. + Arguments should be provided as a dict mapping argument names + to values. """ - from fastmcp.server.server import FastMCP - - # Use FastMCP.render_prompt() if available - runs middleware chain - if isinstance(provider, FastMCP): - result = await provider.render_prompt(name, arguments=arguments or {}) - return _format_prompt_result(result) - - # Fallback for plain providers - no middleware - prompt = await provider.get_prompt(name) - if prompt is None: - raise ValueError(f"Prompt not found: {name}") - - result = await prompt._render(arguments or {}) + ctx = get_context() + result = await ctx.fastmcp.render_prompt(name, arguments=arguments or {}) return _format_prompt_result(result) return Tool.from_function(fn=get_prompt) @@ -162,7 +157,6 @@ def _format_prompt_result(result: Any) -> str: if isinstance(msg.content, TextContent): content = msg.content.text else: - # Preserve structured content (e.g., EmbeddedResource) as dict content = msg.content.model_dump(mode="json", exclude_none=True) messages.append( diff --git a/src/fastmcp/server/transforms/resources_as_tools.py b/src/fastmcp/server/transforms/resources_as_tools.py index 00fc43822..780e513b7 100644 --- a/src/fastmcp/server/transforms/resources_as_tools.py +++ b/src/fastmcp/server/transforms/resources_as_tools.py @@ -3,6 +3,10 @@ This transform generates tools for listing and reading resources, enabling clients that only support tools to access resource functionality. +The generated tools route through `ctx.fastmcp` at runtime, so all server +middleware (auth, visibility, rate limiting, etc.) applies to resource +operations exactly as it would for direct `resources/read` calls. + Example: ```python from fastmcp import FastMCP @@ -21,10 +25,15 @@ import json from collections.abc import Sequence from typing import TYPE_CHECKING, Annotated, Any +from mcp.types import ToolAnnotations + +from fastmcp.server.dependencies import get_context from fastmcp.server.transforms import GetToolNext, Transform -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.versions import VersionSpec +_DEFAULT_ANNOTATIONS = ToolAnnotations(readOnlyHint=True) + if TYPE_CHECKING: from fastmcp.server.providers.base import Provider @@ -33,12 +42,15 @@ class ResourcesAsTools(Transform): """Transform that adds tools for listing and reading resources. Generates two tools: - - `list_resources`: Lists all resources and templates from the provider + - `list_resources`: Lists all resources and templates - `read_resource`: Reads a resource by URI - The transform captures a provider reference at construction and queries it - for resources when the generated tools are called. When used with FastMCP, - the provider's auth and visibility filtering is automatically applied. + The generated tools route through the server at runtime, so auth, + middleware, and visibility apply automatically. + + This transform should be applied to a FastMCP server instance, not + a raw Provider, because the generated tools need the server's + middleware chain for auth and visibility filtering. Example: ```python @@ -49,12 +61,15 @@ class ResourcesAsTools(Transform): """ def __init__(self, provider: Provider) -> None: - """Initialize the transform with a provider reference. + from fastmcp.server.server import FastMCP - Args: - provider: The provider to query for resources. Typically this is - the same FastMCP server the transform is added to. - """ + if not isinstance(provider, FastMCP): + raise TypeError( + "ResourcesAsTools requires a FastMCP server instance, not a" + f" {type(provider).__name__}. The generated tools route through" + " the server's middleware chain at runtime for auth and" + " visibility. Pass your FastMCP server: ResourcesAsTools(mcp)" + ) self._provider = provider def __repr__(self) -> str: @@ -72,31 +87,28 @@ class ResourcesAsTools(Transform): self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name, including generated resource tools.""" - # Check if it's one of our generated tools if name == "list_resources": return self._make_list_resources_tool() if name == "read_resource": return self._make_read_resource_tool() - - # Otherwise delegate to downstream return await call_next(name, version=version) def _make_list_resources_tool(self) -> Tool: """Create the list_resources tool.""" - provider = self._provider async def list_resources() -> str: """List all available resources and resource templates. - Returns JSON with resource metadata. Static resources have a 'uri' field, - while templates have a 'uri_template' field with placeholders like {name}. + Returns JSON with resource metadata. Static resources have a + 'uri' field, while templates have a 'uri_template' field with + placeholders like {name}. """ - resources = await provider.list_resources() - templates = await provider.list_resource_templates() + ctx = get_context() + resources = await ctx.fastmcp.list_resources() + templates = await ctx.fastmcp.list_resource_templates() result: list[dict[str, Any]] = [] - # Static resources for r in resources: result.append( { @@ -107,7 +119,6 @@ class ResourcesAsTools(Transform): } ) - # Resource templates (URI contains placeholders like {name}) for t in templates: result.append( { @@ -119,62 +130,41 @@ class ResourcesAsTools(Transform): return json.dumps(result, indent=2) - return Tool.from_function(fn=list_resources) + return Tool.from_function(fn=list_resources, annotations=_DEFAULT_ANNOTATIONS) def _make_read_resource_tool(self) -> Tool: """Create the read_resource tool.""" - provider = self._provider async def read_resource( uri: Annotated[str, "The URI of the resource to read"], ) -> str: """Read a resource by its URI. - For static resources, provide the exact URI. For templated resources, - provide the URI with template parameters filled in. + For static resources, provide the exact URI. For templated + resources, provide the URI with template parameters filled in. Returns the resource content as a string. Binary content is base64-encoded. """ - from fastmcp import FastMCP + ctx = get_context() + result = await ctx.fastmcp.read_resource(uri) + return _format_result(result) - # Use FastMCP.read_resource() if available - runs middleware chain - if isinstance(provider, FastMCP): - result = await provider.read_resource(uri) - return _format_result(result) - - # Fallback for plain providers - no middleware - resource = await provider.get_resource(uri) - if resource is not None: - result = await resource._read() - return _format_result(result) - - template = await provider.get_resource_template(uri) - if template is not None: - params = template.matches(uri) - if params is not None: - result = await template._read(uri, params) - return _format_result(result) - - raise ValueError(f"Resource not found: {uri}") - - return Tool.from_function(fn=read_resource) + return Tool.from_function(fn=read_resource, annotations=_DEFAULT_ANNOTATIONS) def _format_result(result: Any) -> str: """Format ResourceResult for tool output. - Single text content is returned as-is. Single binary content is base64-encoded. - Multiple contents are JSON-encoded with each item containing content and mime_type. + Single text content is returned as-is. Single binary content is + base64-encoded. Multiple contents are JSON-encoded. """ - # result is a ResourceResult with .contents list if len(result.contents) == 1: content = result.contents[0].content if isinstance(content, bytes): return base64.b64encode(content).decode() return content - # Multiple contents - JSON encode return json.dumps( [ { diff --git a/src/fastmcp/server/transforms/search/base.py b/src/fastmcp/server/transforms/search/base.py index 24743b5d9..7368d62f5 100644 --- a/src/fastmcp/server/transforms/search/base.py +++ b/src/fastmcp/server/transforms/search/base.py @@ -34,7 +34,7 @@ from typing import Annotated, Any from fastmcp.server.context import Context from fastmcp.server.transforms import GetToolNext from fastmcp.server.transforms.catalog import CatalogTransform -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.versions import VersionSpec @@ -229,7 +229,7 @@ class BaseSearchTransform(CatalogTransform): arguments: Annotated[ dict[str, Any] | None, "Arguments to pass to the tool" ] = None, - ctx: Context = None, # type: ignore[assignment] + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] ) -> ToolResult: """Call a tool by name with the given arguments. diff --git a/src/fastmcp/server/transforms/search/bm25.py b/src/fastmcp/server/transforms/search/bm25.py index 8e06ac4be..447db8cac 100644 --- a/src/fastmcp/server/transforms/search/bm25.py +++ b/src/fastmcp/server/transforms/search/bm25.py @@ -12,7 +12,7 @@ from fastmcp.server.transforms.search.base import ( SearchResultSerializer, _extract_searchable_text, ) -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool def _tokenize(text: str) -> list[str]: @@ -115,7 +115,7 @@ class BM25SearchTransform(BaseSearchTransform): async def search_tools( query: Annotated[str, "Natural language query to search for tools"], - ctx: Context = None, # type: ignore[assignment] + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] ) -> str | list[dict[str, Any]]: """Search for tools using natural language. diff --git a/src/fastmcp/server/transforms/search/regex.py b/src/fastmcp/server/transforms/search/regex.py index 8f00bdce6..f1b2d25a5 100644 --- a/src/fastmcp/server/transforms/search/regex.py +++ b/src/fastmcp/server/transforms/search/regex.py @@ -9,7 +9,7 @@ from fastmcp.server.transforms.search.base import ( BaseSearchTransform, _extract_searchable_text, ) -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool class RegexSearchTransform(BaseSearchTransform): @@ -27,7 +27,7 @@ class RegexSearchTransform(BaseSearchTransform): str, "Regex pattern to match against tool names, descriptions, and parameters", ], - ctx: Context = None, # type: ignore[assignment] + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] ) -> str | list[dict[str, Any]]: """Search for tools matching a regex pattern. diff --git a/src/fastmcp/server/transforms/tool_transform.py b/src/fastmcp/server/transforms/tool_transform.py index ed58788da..bd4f168cf 100644 --- a/src/fastmcp/server/transforms/tool_transform.py +++ b/src/fastmcp/server/transforms/tool_transform.py @@ -10,7 +10,7 @@ from fastmcp.tools.tool_transform import ToolTransformConfig from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool class ToolTransform(Transform): diff --git a/src/fastmcp/server/transforms/version_filter.py b/src/fastmcp/server/transforms/version_filter.py index 1b1d0270c..11a586248 100644 --- a/src/fastmcp/server/transforms/version_filter.py +++ b/src/fastmcp/server/transforms/version_filter.py @@ -15,10 +15,10 @@ from fastmcp.server.transforms import ( from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: - from fastmcp.prompts.prompt import Prompt - from fastmcp.resources.resource import Resource + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool class VersionFilter(Transform): diff --git a/src/fastmcp/server/transforms/visibility.py b/src/fastmcp/server/transforms/visibility.py index 5a3588886..e95fd5845 100644 --- a/src/fastmcp/server/transforms/visibility.py +++ b/src/fastmcp/server/transforms/visibility.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any, Literal, TypeVar import mcp.types -from fastmcp.resources.resource import Resource +from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.transforms import ( GetPromptNext, @@ -24,9 +24,9 @@ from fastmcp.server.transforms import ( from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: - from fastmcp.prompts.prompt import Prompt + from fastmcp.prompts.base import Prompt from fastmcp.server.context import Context - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent T = TypeVar("T", bound="FastMCPComponent") diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index bc6d22065..393b1ff2f 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -21,6 +21,10 @@ ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env") LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +MCP_LOG_LEVEL = Literal[ + "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency" +] + DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] TEN_MB_IN_BYTES = 1024 * 1024 * 10 @@ -113,6 +117,21 @@ class DocketSettings(BaseSettings): ), ] = timedelta(seconds=5) + minimum_check_interval: Annotated[ + timedelta, + Field( + description=inspect.cleandoc( + """ + How frequently the worker polls for new tasks. Lower + values reduce latency for task pickup at the cost of + more CPU usage. The default of 50ms is a good balance; + increase for high-volume production deployments where + tasks are long-running. + """ + ), + ), + ] = timedelta(milliseconds=50) + class Settings(BaseSettings): """FastMCP settings.""" @@ -227,6 +246,13 @@ class Settings(BaseSettings): ), ] = None + client_disconnect_timeout: Annotated[ + float, + Field( + description="Maximum time to wait for a clean disconnect before giving up, in seconds.", + ), + ] = 5 + # Transport settings transport: Literal["stdio", "http", "sse", "streamable-http"] = "stdio" @@ -254,6 +280,20 @@ class Settings(BaseSettings): ), ] = False + client_log_level: Annotated[ + MCP_LOG_LEVEL | None, + Field( + description=inspect.cleandoc( + """ + Default minimum log level for messages sent to MCP clients. + When set, log messages below this level are suppressed. + Individual clients can override this per-session using the + MCP logging/setLevel request. + """ + ), + ), + ] = None + strict_input_validation: Annotated[ bool, Field( diff --git a/src/fastmcp/tools/__init__.py b/src/fastmcp/tools/__init__.py index 9aff985ba..64360b2aa 100644 --- a/src/fastmcp/tools/__init__.py +++ b/src/fastmcp/tools/__init__.py @@ -1,7 +1,15 @@ +import sys + from .function_tool import FunctionTool, tool -from .tool import Tool, ToolResult +from .base import 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", "Tool", diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/base.py similarity index 88% rename from src/fastmcp/tools/tool.py rename to src/fastmcp/tools/base.py index b23ebfc8b..b704016fa 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/base.py @@ -26,6 +26,7 @@ from mcp.types import Tool as MCPTool from pydantic import BaseModel, Field, model_validator from pydantic.json_schema import SkipJsonSchema +from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.utilities.components import FastMCPComponent @@ -94,9 +95,11 @@ class ToolResult(BaseModel): # generic serialization, so the renderer gets the right shape. if _HAS_PREFAB: if isinstance(structured_content, _PrefabApp): - structured_content = structured_content.to_json() + structured_content = _prefab_to_json(structured_content) elif isinstance(structured_content, _PrefabComponent): - structured_content = _PrefabApp(view=structured_content).to_json() + structured_content = _prefab_to_json( + _PrefabApp(view=structured_content) + ) try: structured_content = pydantic_core.to_jsonable_python( @@ -127,7 +130,7 @@ class ToolResult(BaseModel): return CallToolResult( structuredContent=self.structured_content, content=self.content, - _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] ) if self.structured_content is None: return self.content @@ -188,7 +191,7 @@ class Tool(FastMCPComponent): elif self.annotations and self.annotations.title: title = self.annotations.title - return MCPTool( + mcp_tool = MCPTool( name=overrides.get("name", self.name), title=overrides.get("title", title), description=overrides.get("description", self.description), @@ -199,9 +202,18 @@ class Tool(FastMCPComponent): execution=overrides.get("execution", self.execution), _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field "_meta", self.get_meta() - ), + ), # ty:ignore[unknown-argument] ) + if ( + self.task_config.supports_tasks() + and "execution" not in overrides + and not self.execution + ): + mcp_tool.execution = ToolExecution(taskSupport=self.task_config.mode) + + return mcp_tool + @classmethod def from_function( cls, @@ -266,9 +278,15 @@ class Tool(FastMCPComponent): if _HAS_PREFAB: if isinstance(raw_value, _PrefabApp): - return _prefab_to_tool_result(raw_value) + return _prefab_to_tool_result( + raw_value, + fastmcp_app_name=_get_fastmcp_app_name(self), + ) if isinstance(raw_value, _PrefabComponent): - return _prefab_to_tool_result(_PrefabApp(view=raw_value)) + return _prefab_to_tool_result( + _PrefabApp(view=raw_value), + fastmcp_app_name=_get_fastmcp_app_name(self), + ) content = _convert_to_content(raw_value, serializer=self.serializer) @@ -299,6 +317,7 @@ class Tool(FastMCPComponent): return ToolResult( content=content, structured_content={"result": structured} if wrap_result else structured, + meta={"fastmcp": {"wrap_result": True}} if wrap_result else None, ) @overload @@ -479,11 +498,45 @@ def _convert_to_single_content_block( _PREFAB_TEXT_FALLBACK = "[Rendered Prefab UI]" -def _prefab_to_tool_result(app: Any) -> ToolResult: +def _get_tool_resolver(app_name: str | None = None) -> Callable[..., str] | None: + """Get the FastMCPApp callable resolver, if available.""" + try: + from fastmcp.apps.app import _make_resolver + + return _make_resolver(app_name) + except ImportError: + return None + + +def _prefab_to_json(app: Any, fastmcp_app_name: str | None = None) -> dict[str, Any]: + """Call PrefabApp.to_json() with the FastMCPApp callable resolver. + + The resolver prefixes tool names with the app name (e.g. + ``"store_files"`` → ``"Files___store_files"``) so the server can + find them via the bypass lookup regardless of transforms. + """ + data = app.to_json(tool_resolver=_get_tool_resolver(fastmcp_app_name)) + return data + + +def _get_fastmcp_app_name(tool: Tool) -> str | None: + """Read the FastMCPApp name from a tool's metadata, if present.""" + meta = tool.meta + if not meta: + return None + fastmcp_meta = meta.get("fastmcp") + if isinstance(fastmcp_meta, dict): + app = fastmcp_meta.get("app") + if isinstance(app, str): + return app + return None + + +def _prefab_to_tool_result(app: Any, fastmcp_app_name: str | None = None) -> ToolResult: """Convert a PrefabApp to a FastMCP ToolResult.""" return ToolResult( content=[TextContent(type="text", text=_PREFAB_TEXT_FALLBACK)], - structured_content=app.to_json(), + structured_content=_prefab_to_json(app, fastmcp_app_name=fastmcp_app_name), ) @@ -534,7 +587,7 @@ def __getattr__(name: str) -> Any: warnings.warn( f"Importing {name} from fastmcp.tools.tool is deprecated. " f"Import from fastmcp.tools.function_tool instead.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) from fastmcp.tools import function_tool diff --git a/src/fastmcp/tools/function_parsing.py b/src/fastmcp/tools/function_parsing.py index a056c37c9..804dc6efd 100644 --- a/src/fastmcp/tools/function_parsing.py +++ b/src/fastmcp/tools/function_parsing.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import inspect import types from collections.abc import Callable @@ -16,7 +17,7 @@ from fastmcp.server.dependencies import ( transform_context_annotations, without_injected_parameters, ) -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.base import ToolResult from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( @@ -25,6 +26,7 @@ from fastmcp.utilities.types import ( Image, create_function_without_params, get_cached_typeadapter, + is_class_member_of_type, replace_type, ) @@ -63,8 +65,16 @@ class _UnserializableType: pass -def _is_object_schema(schema: dict[str, Any]) -> bool: +def _is_object_schema( + schema: dict[str, Any], + *, + _root_schema: dict[str, Any] | None = None, + _seen_refs: set[str] | None = None, +) -> bool: """Check if a JSON schema represents an object type.""" + root_schema = _root_schema or schema + seen_refs = _seen_refs or set() + # Direct object type if schema.get("type") == "object": return True @@ -73,9 +83,34 @@ def _is_object_schema(schema: dict[str, Any]) -> bool: if "properties" in schema: return True - # Self-referencing types use $ref pointing to $defs - # The referenced type is always an object in our use case - return "$ref" in schema and "$defs" in schema + # Resolve local $ref definitions and recurse into the target schema. + ref = schema.get("$ref") + if not isinstance(ref, str) or not ref.startswith("#/"): + return False + + if ref in seen_refs: + return False + + # Walk the JSON Pointer path from the root schema, unescaping each + # token per RFC 6901 (~1 → /, ~0 → ~). + pointer = ref.removeprefix("#/") + segments = pointer.split("/") + target: Any = root_schema + for segment in segments: + unescaped = segment.replace("~1", "/").replace("~0", "~") + if not isinstance(target, dict) or unescaped not in target: + return False + target = target[unescaped] + + target_schema = target + if not isinstance(target_schema, dict): + return False + + return _is_object_schema( + target_schema, + _root_schema=root_schema, + _seen_refs=seen_refs | {ref}, + ) @dataclass @@ -124,7 +159,7 @@ class ParsedFunction: fn_doc = inspect.getdoc(fn) # if the fn is a callable class, we need to get the __call__ method from here out - if not inspect.isroutine(fn): + if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): fn = fn.__call__ # if the fn is a staticmethod, we need to work with the underlying function if isinstance(fn, staticmethod): @@ -178,6 +213,11 @@ class ParsedFunction: if _PREFAB_TYPES and _contains_prefab_type(output_type): output_type = _UnserializableType + # ToolResult subclasses should suppress schema generation just + # like ToolResult itself — replace_type only does exact matching. + if is_class_member_of_type(output_type, ToolResult): + output_type = _UnserializableType + # there are a variety of types that we don't want to attempt to # serialize because they are either used by FastMCP internally, # or are MCP content types that explicitly don't form structured diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index e88828a80..0f0dc8325 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import inspect import warnings from collections.abc import Callable @@ -18,24 +19,32 @@ from typing import ( ) import anyio -import mcp.types from mcp.shared.exceptions import McpError -from mcp.types import ErrorData, Icon, ToolAnnotations, ToolExecution +from mcp.types import ErrorData, Icon, ToolAnnotations from pydantic import Field from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config +from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.dependencies import without_injected_parameters +from fastmcp.server.dependencies import ( + _restore_task_http_headers, + _task_http_headers, + get_task_context, + without_injected_parameters, +) from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.function_parsing import ParsedFunction, _is_object_schema -from fastmcp.tools.tool import ( +from fastmcp.tools.base import ( Tool, ToolResult, ToolResultSerializerType, ) -from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool +from fastmcp.tools.function_parsing import ParsedFunction, _is_object_schema +from fastmcp.utilities.async_utils import ( + call_sync_fn_in_threadpool, + is_coroutine_function, +) from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( NotSet, @@ -88,24 +97,6 @@ class FunctionTool(Tool): fn: SkipJsonSchema[Callable[..., Any]] return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None - def to_mcp_tool( - self, - **overrides: Any, - ) -> mcp.types.Tool: - """Convert the FastMCP tool to an MCP tool. - - Extends the base implementation to add task execution mode if enabled. - """ - # Get base MCP tool from parent - mcp_tool = super().to_mcp_tool(**overrides) - - # Add task execution mode per SEP-1686 - # Only set execution if not overridden and task execution is supported - if self.task_config.supports_tasks() and "execution" not in overrides: - mcp_tool.execution = ToolExecution(taskSupport=self.task_config.mode) - - return mcp_tool - @classmethod def from_function( cls, @@ -190,7 +181,7 @@ class FunctionTool(Tool): "The `serializer` parameter is deprecated. " "Return ToolResult from your tools for full control over serialization. " "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) if metadata.exclude_args and fastmcp.settings.deprecation_warnings: @@ -198,7 +189,7 @@ class FunctionTool(Tool): "The `exclude_args` parameter is deprecated as of FastMCP 2.14. " "Use dependency injection with `Depends()` instead for better lifecycle management. " "See https://gofastmcp.com/servers/dependency-injection#using-depends for examples.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) @@ -260,7 +251,7 @@ class FunctionTool(Tool): try: with anyio.fail_after(self.timeout): # Thread pool execution for sync functions, direct await for async - if inspect.iscoroutinefunction(wrapper_fn): + if is_coroutine_function(wrapper_fn): result = await type_adapter.validate_python(arguments) else: # Sync function: run in threadpool to avoid blocking @@ -284,7 +275,7 @@ class FunctionTool(Tool): ) from None else: # No timeout: use existing execution path - if inspect.iscoroutinefunction(wrapper_fn): + if is_coroutine_function(wrapper_fn): result = await type_adapter.validate_python(arguments) else: result = await call_sync_fn_in_threadpool( @@ -299,11 +290,13 @@ class FunctionTool(Tool): """Register this tool with docket for background execution. FunctionTool registers the underlying function, which has the user's - Depends parameters for docket to resolve. + Depends parameters for docket to resolve. The function is wrapped to + eagerly restore HTTP headers from Redis so that get_http_request() + works even without explicit dependency injection. """ if not self.task_config.supports_tasks(): return - docket.register(self.fn, names=[self.key]) + docket.register(_wrap_for_task_http_headers(self.fn), names=[self.key]) async def add_to_docket( self, @@ -331,6 +324,34 @@ class FunctionTool(Tool): return await docket.add(lookup_key, **kwargs)(**arguments) +def _wrap_for_task_http_headers(fn: Callable[..., Any]) -> Callable[..., Any]: + """Wrap a function to restore HTTP headers in background task workers. + + Uses functools.wraps so docket sees the original signature for dependency + resolution while the wrapper eagerly populates _task_http_headers before + the user's function runs. + """ + + @functools.wraps(fn) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + task_info = get_task_context() + token = None + if task_info is not None and _task_http_headers.get() is None: + token = await _restore_task_http_headers( + task_info.session_id, task_info.task_id + ) + try: + result = fn(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + return result + finally: + if token is not None: + _task_http_headers.reset(token) + + return wrapper + + @overload def tool(fn: F) -> F: ... @overload @@ -450,10 +471,10 @@ def tool( warnings.warn( "decorator_mode='object' is deprecated and will be removed in a future version. " "Decorators now return the original function with metadata attached.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=4, ) - return create_tool(fn, tool_name) # type: ignore[return-value] + return create_tool(fn, tool_name) # type: ignore[return-value] # ty:ignore[invalid-return-type] return attach_metadata(fn, tool_name) if inspect.isroutine(name_or_fn): diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 9fc63e6e3..a1ec42302 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -16,8 +16,9 @@ from pydantic.functional_validators import BeforeValidator from pydantic.json_schema import SkipJsonSchema import fastmcp +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.tools.base import Tool, ToolResult, _convert_to_content from fastmcp.tools.function_parsing import ParsedFunction -from fastmcp.tools.tool import Tool, ToolResult, _convert_to_content from fastmcp.utilities.components import _convert_set_default_none from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger @@ -26,6 +27,7 @@ from fastmcp.utilities.types import ( NotSet, NotSetT, get_cached_typeadapter, + issubclass_safe, ) logger = get_logger(__name__) @@ -313,16 +315,7 @@ class TransformedTool(Tool): # If transform function returns ToolResult, respect our output_schema setting if isinstance(result, ToolResult): if self.output_schema is None: - # Check if this is from a custom function that returns ToolResult - - return_annotation = inspect.signature(self.fn).return_annotation - if return_annotation is ToolResult: - # Custom function returns ToolResult - preserve its content - return result - else: - # Forwarded call with no explicit schema - preserve parent's structured content - # The parent tool may have generated structured content via its own fallback logic - return result + return result elif self.output_schema.get( "type" ) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"): @@ -467,7 +460,7 @@ class TransformedTool(Tool): "The `serializer` parameter is deprecated. " "Return ToolResult from your tools for full control over serialization. " "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - DeprecationWarning, + FastMCPDeprecationWarning, stacklevel=2, ) transform_args = transform_args or {} @@ -496,11 +489,11 @@ class TransformedTool(Tool): # parsed fn is not none here final_output_schema = cast(ParsedFunction, parsed_fn).output_schema if final_output_schema is None: - # Check if function returns ToolResult - if so, don't fall back to parent - return_annotation = inspect.signature( - transform_fn - ).return_annotation - if return_annotation is ToolResult: + # Check if function returns ToolResult (or subclass) - if so, don't fall back to parent. + # Use parsed_fn.return_type (resolved via get_type_hints) instead of + # inspect.signature, which returns strings under `from __future__ import annotations`. + return_type = cast(ParsedFunction, parsed_fn).return_type + if issubclass_safe(return_type, ToolResult): final_output_schema = None else: final_output_schema = tool.output_schema @@ -554,12 +547,16 @@ class TransformedTool(Tool): # Additional validation: check for naming conflicts after transformation if transform_args: new_names = [] - for old_name, transform in transform_args.items(): - if not transform.hide: - if transform.name is not NotSet: - new_names.append(transform.name) - else: - new_names.append(old_name) + for old_name in parent_params: + transform = transform_args.get(old_name, ArgTransform()) + + if transform.hide: + continue + + if transform.name is not NotSet: + new_names.append(transform.name) + else: + new_names.append(old_name) # Check for duplicate names after transformation name_counts = {} diff --git a/src/fastmcp/types.py b/src/fastmcp/types.py new file mode 100644 index 000000000..f078ceb6b --- /dev/null +++ b/src/fastmcp/types.py @@ -0,0 +1,32 @@ +"""Reusable type annotations for FastMCP tool parameters. + +These types can be used in tool function signatures to influence how +parameters are presented in UIs (e.g. ``fastmcp dev apps``) and +serialized in JSON Schema. + +Example:: + + from fastmcp import FastMCP + from fastmcp.types import Textarea + + mcp = FastMCP("demo") + + @mcp.tool() + def run_query(sql: Textarea) -> str: + ... +""" + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field + +Textarea = Annotated[str, Field(json_schema_extra={"format": "textarea"})] +"""A string rendered as a multiline textarea in form-based UIs. + +Produces ``"format": "textarea"`` in the JSON Schema, which +``fastmcp dev apps`` picks up automatically. +""" + +__all__ = ["Textarea"] diff --git a/src/fastmcp/utilities/async_utils.py b/src/fastmcp/utilities/async_utils.py index aa8caafc7..3f7e816fb 100644 --- a/src/fastmcp/utilities/async_utils.py +++ b/src/fastmcp/utilities/async_utils.py @@ -1,6 +1,8 @@ """Async utilities for FastMCP.""" +import asyncio import functools +import inspect from collections.abc import Awaitable, Callable from typing import Any, Literal, TypeVar, overload @@ -10,6 +12,18 @@ from anyio.to_thread import run_sync as run_sync_in_threadpool T = TypeVar("T") +def is_coroutine_function(fn: Any) -> bool: + """Check if a callable is a coroutine function, unwrapping functools.partial. + + ``inspect.iscoroutinefunction`` returns ``False`` for + ``functools.partial`` objects wrapping an async function on Python < 3.12. + This helper unwraps any layers of ``partial`` before checking. + """ + while isinstance(fn, functools.partial): + fn = fn.func + return inspect.iscoroutinefunction(fn) or asyncio.iscoroutinefunction(fn) + + async def call_sync_fn_in_threadpool( fn: Callable[..., Any], *args: Any, **kwargs: Any ) -> Any: @@ -51,7 +65,7 @@ async def gather( Returns: List of results in the same order as input awaitables. """ - results: list[T | BaseException] = [None] * len(awaitables) # type: ignore[assignment] + results: list[T | BaseException] = [None] * len(awaitables) # type: ignore[assignment] # ty:ignore[invalid-assignment] async def run_at(i: int, aw: Awaitable[T]) -> None: try: diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py index 070931fa6..cac3e65e3 100644 --- a/src/fastmcp/utilities/cli.py +++ b/src/fastmcp/utilities/cli.py @@ -221,7 +221,7 @@ def log_server_banner(server: FastMCP[Any]) -> None: if server.version: server_info += f", {server.version}" info_table.add_row("🖥", "Server:", Text(server_info, style="dim")) - info_table.add_row("🚀", "Deploy free:", "https://fastmcp.cloud") + info_table.add_row("🚀", "Deploy free:", "https://horizon.prefect.io") # Create panel with logo, title, and information using Group docs_url = Text("https://gofastmcp.com", style="dim") diff --git a/src/fastmcp/utilities/components.py b/src/fastmcp/utilities/components.py index 168599807..34ab62c3b 100644 --- a/src/fastmcp/utilities/components.py +++ b/src/fastmcp/utilities/components.py @@ -31,7 +31,13 @@ def get_fastmcp_metadata(meta: dict[str, Any] | None) -> FastMCPMeta: """ if not meta: return {} - return cast(FastMCPMeta, meta.get("fastmcp") or meta.get("_fastmcp") or {}) + + for key in ("fastmcp", "_fastmcp"): + metadata = meta.get(key) + if isinstance(metadata, dict): + return cast(FastMCPMeta, metadata) + + return {} def _convert_set_default_none(maybe_set: set[T] | Sequence[T] | None) -> set[T]: @@ -43,13 +49,20 @@ def _convert_set_default_none(maybe_set: set[T] | Sequence[T] | None) -> set[T]: return set(maybe_set) -def _coerce_version(v: str | int | None) -> str | None: - """Coerce version to string, accepting int or str. +def _coerce_version(v: str | int | float | None) -> str | None: + """Coerce version to string, accepting int, float, or str. + Raises TypeError for non-scalar types (list, dict, set, etc.). Raises ValueError if version contains '@' (used as key delimiter). """ if v is None: return None + if isinstance(v, bool): + raise TypeError(f"Version must be a string, int, or float, got bool: {v!r}") + if not isinstance(v, (str, int, float)): + raise TypeError( + f"Version must be a string, int, or float, got {type(v).__name__}: {v!r}" + ) version = str(v) if "@" in version: raise ValueError( @@ -200,7 +213,7 @@ class FastMCPComponent(FastMCPBaseModel): f"Use server.disable(keys=['{self.key}']) instead." ) - def copy(self) -> Self: # type: ignore[override] + def copy(self) -> Self: # type: ignore[override] # ty:ignore[invalid-method-override] """Create a copy of the component.""" return self.model_copy() diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index fc0a0d759..fc4e069c6 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -53,6 +53,53 @@ def _defs_have_cycles(defs: dict[str, Any]) -> bool: return any(state[name] == UNVISITED and _has_cycle(name) for name in defs) +def _strip_remote_refs(obj: Any) -> Any: + """Return a deep copy of *obj* with non-local ``$ref`` values removed. + + Local refs (starting with ``#``) are kept intact. Remote refs + (``http://``, ``https://``, ``file://``, or any other URI scheme) are + stripped so that ``jsonref.replace_refs`` never attempts to fetch an + external resource. This prevents SSRF / LFI when proxying schemas + from untrusted servers. + """ + if isinstance(obj, dict): + ref = obj.get("$ref") + if isinstance(ref, str) and not ref.startswith("#"): + # Drop the remote $ref key; keep all other keys. + return {k: _strip_remote_refs(v) for k, v in obj.items() if k != "$ref"} + return {k: _strip_remote_refs(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_strip_remote_refs(item) for item in obj] + return obj + + +def _strip_discriminator(obj: Any) -> Any: + """Recursively remove OpenAPI ``discriminator`` keys from a schema. + + Pydantic emits ``discriminator.mapping`` with values like + ``#/$defs/ClassName``. After ``$defs`` are inlined and removed by + ``dereference_refs``, those mapping entries dangle. The keyword is an + OpenAPI extension — the ``anyOf`` variants already carry ``const`` on + the discriminant field, so the mapping is redundant. + + Only strips ``discriminator`` when it appears alongside ``anyOf`` or + ``oneOf``, which is where the OpenAPI keyword lives. A property + *named* ``discriminator`` (inside ``properties``) is left alone. + """ + if isinstance(obj, dict): + skip = "discriminator" in obj and ("anyOf" in obj or "oneOf" in obj) + # Keys that hold instance data, not sub-schemas — don't recurse. + _DATA_KEYS = {"default", "const", "examples", "enum"} + return { + k: (v if k in _DATA_KEYS else _strip_discriminator(v)) + for k, v in obj.items() + if not (k == "discriminator" and skip) + } + if isinstance(obj, list): + return [_strip_discriminator(item) for item in obj] + return obj + + def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: """Resolve all $ref references in a JSON schema by inlining definitions. @@ -67,6 +114,11 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: this function falls back to resolving only the root-level $ref while preserving $defs for nested references. + Only local ``$ref`` values (those starting with ``#``) are resolved. + Remote URIs (``http://``, ``file://``, etc.) are stripped before + resolution to prevent SSRF / local-file-inclusion attacks when proxying + schemas from untrusted servers. + Args: schema: JSON schema dict that may contain $ref references @@ -82,6 +134,9 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: >>> resolved = dereference_refs(schema) >>> # Result: {"properties": {"cat": {"enum": ["a", "b"], "type": "string", "default": "a"}}} """ + # Strip any remote $ref values before processing to prevent SSRF / LFI. + schema = _strip_remote_refs(schema) + # Circular $defs can't be fully inlined — jsonref.replace_refs produces # Python dicts with object-identity cycles that Pydantic's model_dump # rejects with "Circular reference detected (id repeated)". @@ -107,6 +162,13 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: if "$defs" in dereferenced: dereferenced = {k: v for k, v in dereferenced.items() if k != "$defs"} + # Strip `discriminator` keys — they contain `mapping` values that + # point at `#/$defs/...` entries we just removed. `discriminator` + # is an OpenAPI extension; after inlining, the `anyOf` variants + # already carry `const` on the discriminant field, making the + # mapping redundant. + dereferenced = _strip_discriminator(dereferenced) + return dereferenced except JsonRefError: @@ -331,8 +393,10 @@ def _single_pass_optimize( # Schema objects have keywords like "type", "properties", "$ref", etc. # If we see these, then "title" is metadata, not a property name if prune_titles and "title" in node: - # Check if this looks like a schema node - if any( + # Only remove "title" if it's a string (schema metadata). + # In a "properties" dict, "title" would be a dict (a sub-schema + # for a parameter named "title"), which we must preserve. + if isinstance(node["title"], str) and any( # type: ignore k in node for k in [ "type", diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py index 3f1470f6f..e45bab71a 100644 --- a/src/fastmcp/utilities/json_schema_type.py +++ b/src/fastmcp/utilities/json_schema_type.py @@ -177,7 +177,7 @@ def json_schema_to_type( # Handle typed dictionaries like dict[str, str] value_type = _schema_to_type(additional_props, schemas=schema) # value_type might be ForwardRef or type - cast to Any for dynamic type construction - return cast(type[Any], dict[str, value_type]) # type: ignore[valid-type] + return cast(type[Any], dict[str, value_type]) # type: ignore[valid-type] # ty:ignore[invalid-type-form] # If no properties and no additionalProperties, default to dict[str, Any] for safety elif not schema.get("properties") and not schema.get("additionalProperties"): return dict[str, Any] @@ -189,7 +189,7 @@ def json_schema_to_type( elif name: raise ValueError(f"Can not apply name to non-object schema: {name}") result = _schema_to_type(schema, schemas=schema) - return result # type: ignore[return-value] + return result # type: ignore[return-value] # ty:ignore[invalid-return-type] def _hash_schema(schema: Mapping[str, Any]) -> str: @@ -250,13 +250,13 @@ def _create_numeric_type( if v is not None } - return Annotated[base, Field(**constraints)] if constraints else base # type: ignore[return-value] + return Annotated[base, Field(**constraints)] if constraints else base # type: ignore[return-value] # ty:ignore[invalid-type-form] def _create_enum(name: str, values: list[Any]) -> type: """Create enum type from list of values.""" # Always return Literal for enum fields to preserve the literal nature - return Literal[tuple(values)] # type: ignore[return-value] + return Literal[tuple(values)] # type: ignore[return-value] # ty:ignore[invalid-type-form] def _create_array_type( @@ -268,7 +268,7 @@ def _create_array_type( # Handle positional item schemas item_types = [_schema_to_type(s, schemas) for s in items] combined = Union[tuple(item_types)] # noqa: UP007 - base = list[combined] # type: ignore[valid-type] + base = list[combined] # type: ignore[valid-type] # ty:ignore[invalid-type-form] else: # Handle single item schema item_type = _schema_to_type(items, schemas) @@ -284,7 +284,7 @@ def _create_array_type( if v is not None } - return Annotated[base, Field(**constraints)] if constraints else base # type: ignore[return-value] + return Annotated[base, Field(**constraints)] if constraints else base # type: ignore[return-value] # ty:ignore[invalid-type-form] def _return_Any() -> Any: @@ -461,7 +461,7 @@ def _create_pydantic_model( if cache_key in _classes: existing = _classes[cache_key] if existing is None: - return ForwardRef(sanitized_name) # type: ignore[return-value] + return ForwardRef(sanitized_name) # type: ignore[return-value] # ty:ignore[invalid-return-type] return existing # Place placeholder for recursive references @@ -485,7 +485,7 @@ def _create_pydantic_model( elif prop_name in required: annotations[prop_name] = field_type else: - annotations[prop_name] = Union[field_type, type(None)] # type: ignore[misc] # noqa: UP007 + annotations[prop_name] = Union[field_type, type(None)] # type: ignore[misc] # noqa: UP007 # ty:ignore[invalid-type-form] defaults[prop_name] = None # Create Pydantic model class @@ -521,7 +521,7 @@ def _create_dataclass( if cache_key in _classes: existing = _classes[cache_key] if existing is None: - return ForwardRef(sanitized_name) # type: ignore[return-value] + return ForwardRef(sanitized_name) # type: ignore[return-value] # ty:ignore[invalid-return-type] return existing # Place placeholder for recursive references @@ -530,7 +530,7 @@ def _create_dataclass( if "$ref" in schema: ref = schema["$ref"] if ref == "#": - return ForwardRef(sanitized_name) # type: ignore[return-value] + return ForwardRef(sanitized_name) # type: ignore[return-value] # ty:ignore[invalid-return-type] schema = _resolve_ref(ref, schemas or {}) properties = schema.get("properties", {}) @@ -568,7 +568,7 @@ def _create_dataclass( if is_required or default_val is not MISSING: fields.append((field_name, field_type, field_def)) else: - fields.append((field_name, Union[field_type, type(None)], field_def)) # type: ignore[misc] # noqa: UP007 + fields.append((field_name, Union[field_type, type(None)], field_def)) # type: ignore[misc] # noqa: UP007 # ty:ignore[invalid-type-form] cls = make_dataclass(sanitized_name, fields, kw_only=True) @@ -580,7 +580,7 @@ def _create_dataclass( return _merge_defaults(data, original_schema) return data - cls._apply_defaults = _apply_defaults # type: ignore[attr-defined] + cls._apply_defaults = _apply_defaults # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] # Store completed class _classes[cache_key] = cls diff --git a/src/fastmcp/utilities/logging.py b/src/fastmcp/utilities/logging.py index 0c2907b48..1cd0470d7 100644 --- a/src/fastmcp/utilities/logging.py +++ b/src/fastmcp/utilities/logging.py @@ -98,7 +98,7 @@ def configure_logging( # Override defaults with user-provided values traceback_kwargs.update(rich_kwargs) - traceback_handler = RichHandler(**traceback_kwargs) # type: ignore[arg-type] + traceback_handler = RichHandler(**traceback_kwargs) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] traceback_handler.setFormatter(formatter) traceback_handler.addFilter(lambda record: record.exc_info is not None) diff --git a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py index 5858680cc..b926bebcd 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py @@ -192,7 +192,7 @@ class MCPServerConfig(BaseModel): """ if isinstance(v, dict): return FileSystemSource(**v) - return v # type: ignore[return-value] + return v # type: ignore[return-value] # ty:ignore[invalid-return-type] @field_validator("environment", mode="before") @classmethod @@ -217,7 +217,7 @@ class MCPServerConfig(BaseModel): """ if isinstance(v, dict): return Deployment(**v) - return cast(Deployment, v) # type: ignore[return-value] + return cast(Deployment, v) # type: ignore[return-value] # ty:ignore[redundant-cast] @classmethod def from_file(cls, file_path: Path) -> MCPServerConfig: diff --git a/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py index b5e64c8a8..f94dc7c97 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py @@ -6,6 +6,7 @@ from typing import Any, Literal from pydantic import Field, field_validator +from fastmcp.utilities.async_utils import is_coroutine_function from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.sources.base import Source @@ -182,11 +183,11 @@ class FileSystemSource(Source): from fastmcp.server.server import FastMCP # Check if it's a function or coroutine function - if inspect.isfunction(obj) or inspect.iscoroutinefunction(obj): + if inspect.isfunction(obj) or is_coroutine_function(obj): logger.debug(f"Found factory function '{name}' in {file_path}") try: - if inspect.iscoroutinefunction(obj): + if is_coroutine_function(obj): # Async factory function server = await obj() else: diff --git a/src/fastmcp/utilities/mime.py b/src/fastmcp/utilities/mime.py new file mode 100644 index 000000000..73b912e7c --- /dev/null +++ b/src/fastmcp/utilities/mime.py @@ -0,0 +1,27 @@ +"""MIME type constants and helpers for MCP Apps UI resources. + +This module has no dependencies on the server or resource packages, +so it can be safely imported from anywhere. +""" + +UI_MIME_TYPE = "text/html;profile=mcp-app" + + +def resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None: + """Return the appropriate MIME type for a resource URI. + + For ``ui://`` scheme resources, defaults to ``UI_MIME_TYPE`` when no + explicit MIME type is provided. + + Args: + uri: The resource URI string + explicit_mime_type: The MIME type explicitly provided by the user + + Returns: + The resolved MIME type (explicit value, UI default, or None) + """ + if explicit_mime_type is not None: + return explicit_mime_type + if uri.lower().startswith("ui://"): + return UI_MIME_TYPE + return None diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index 58e941ba7..8980e3d6a 100644 --- a/src/fastmcp/utilities/openapi/director.py +++ b/src/fastmcp/utilities/openapi/director.py @@ -1,18 +1,30 @@ """Request director using openapi-core for stateless HTTP request building.""" -from typing import Any -from urllib.parse import urljoin +import json as _json +from typing import Any, ClassVar +from urllib.parse import quote, urljoin import httpx from jsonschema_path import SchemaPath from fastmcp.utilities.logging import get_logger -from .models import HTTPRoute +from .models import HTTPRoute, ParameterInfo logger = get_logger(__name__) +def _query_scalar_to_str(value: Any) -> str: + """Convert a scalar to its query-string representation. + + Booleans are lowercased to match JSON/OpenAPI conventions (true/false) + rather than Python's str(True) → "True". + """ + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + class RequestDirector: """Builds httpx.Request objects from HTTPRoute and arguments using openapi-core.""" @@ -50,24 +62,45 @@ class RequestDirector: f"Unflattened - path: {path_params}, query: {query_params}, headers: {header_params}, body: {body}" ) - # Step 2: Build base URL with path parameters + # Step 2: Serialize query parameters according to OpenAPI style/explode + query_params = self._serialize_query_params(route, query_params) + + # Step 3: Build base URL with path parameters url = self._build_url(route.path, path_params, base_url) - # Step 3: Prepare request data + # Step 4: Prepare request data method: str = route.method.upper() params = query_params if query_params else None headers = header_params if header_params else None json_body: dict[str, Any] | list[Any] | None = None content: str | bytes | None = None - # Step 4: Handle request body + # Step 5: Determine the declared content type from the OpenAPI spec + declared_content_type: str | None = None + if route.request_body and route.request_body.content_schema: + declared_content_type = next(iter(route.request_body.content_schema)) + + # Step 6: Handle request body if body is not None: if isinstance(body, dict | list): - json_body = body + if ( + declared_content_type is not None + and declared_content_type != "application/json" + and "json" in declared_content_type + ): + # JSON-compatible types like application/json-patch+json + # or application/merge-patch+json need an explicit + # Content-Type header since httpx's json= always + # sets application/json. + content = _json.dumps(body, allow_nan=False).encode("utf-8") + headers = dict(headers) if headers else {} + headers["Content-Type"] = declared_content_type + else: + json_body = body else: content = body - # Step 5: Create httpx.Request + # Step 7: Create httpx.Request return httpx.Request( method=method, url=url, @@ -191,6 +224,76 @@ class RequestDirector: return path_params, query_params, header_params, body + # Delimiter per OpenAPI style when explode=false + _STYLE_DELIMITERS: ClassVar[dict[str, str]] = { + "form": ",", + "spaceDelimited": " ", + "pipeDelimited": "|", + } + + def _serialize_query_params( + self, + route: HTTPRoute, + query_params: dict[str, Any], + ) -> dict[str, Any]: + """ + Serialize query parameter values according to their OpenAPI style/explode settings. + + By default (style=form, explode=true), list values are passed through as-is + so httpx repeats the key (e.g. values=a&values=b). When explode=false, + list values are joined with the style-appropriate delimiter: + - form (default): comma (values=a,b) + - pipeDelimited: pipe (values=a|b) + - spaceDelimited: space (values=a%20b) + """ + if not query_params: + return query_params + + # Build a lookup from openapi_name -> ParameterInfo for query params + param_lookup: dict[str, ParameterInfo] = { + p.name: p for p in route.parameters if p.location == "query" + } + + serialized: dict[str, Any] = {} + for key, value in query_params.items(): + param_info = param_lookup.get(key) + if param_info is not None: + explode = param_info.explode if param_info.explode is not None else True + if isinstance(value, dict): + if not value: + continue + if explode: + # form,explode=true on objects: each property becomes + # a separate query parameter. + # e.g. {"R": 100, "G": 200} → R=100&G=200 + for k, v in value.items(): + serialized[_query_scalar_to_str(k)] = _query_scalar_to_str( + v + ) + else: + style = param_info.style or "form" + delimiter = self._STYLE_DELIMITERS.get(style, ",") + # form,explode=false on objects: key,value pairs + # e.g. {"R": 100, "G": 200} → "R,100,G,200" + parts: list[str] = [] + for k, v in value.items(): + parts.append(_query_scalar_to_str(k)) + parts.append(_query_scalar_to_str(v)) + serialized[key] = delimiter.join(parts) + continue + if not explode: + style = param_info.style or "form" + delimiter = self._STYLE_DELIMITERS.get(style, ",") + if isinstance(value, list): + if not value: + continue + serialized[key] = delimiter.join( + _query_scalar_to_str(v) for v in value + ) + continue + serialized[key] = value + return serialized + def _build_url( self, path_template: str, path_params: dict[str, Any], base_url: str ) -> str: @@ -205,12 +308,14 @@ class RequestDirector: Returns: Complete URL with path parameters substituted """ - # Substitute path parameters + # Substitute path parameters with URL-encoding to prevent + # path traversal and SSRF via crafted parameter values url_path = path_template for param_name, param_value in path_params.items(): placeholder = f"{{{param_name}}}" if placeholder in url_path: - url_path = url_path.replace(placeholder, str(param_value)) + safe_value = quote(str(param_value), safe="").replace(".", "%2E") + url_path = url_path.replace(placeholder, safe_value) # Combine with base URL return urljoin(base_url.rstrip("/") + "/", url_path.lstrip("/")) diff --git a/src/fastmcp/utilities/openapi/parser.py b/src/fastmcp/utilities/openapi/parser.py index 40adf8d27..f83a45249 100644 --- a/src/fastmcp/utilities/openapi/parser.py +++ b/src/fastmcp/utilities/openapi/parser.py @@ -763,7 +763,7 @@ class OpenAPIParser( # Create initial route without pre-calculated fields route = HTTPRoute( path=path_str, - method=method_upper, # type: ignore[arg-type] # Known valid HTTP method + method=method_upper, # type: ignore[arg-type] # Known valid HTTP method # ty:ignore[invalid-argument-type] operation_id=getattr(operation, "operationId", None), summary=getattr(operation, "summary", None), description=getattr(operation, "description", None), diff --git a/src/fastmcp/utilities/skills.py b/src/fastmcp/utilities/skills.py index a84728fe3..49b13d859 100644 --- a/src/fastmcp/utilities/skills.py +++ b/src/fastmcp/utilities/skills.py @@ -164,7 +164,11 @@ async def download_skill( ``` """ target_dir = Path(target_dir).expanduser().resolve() - skill_dir = target_dir / skill_name + skill_dir = (target_dir / skill_name).resolve() + + # Security: ensure skill_dir stays within target_dir + if not skill_dir.is_relative_to(target_dir): + raise ValueError(f"Skill name {skill_name!r} would escape the target directory") # Check if directory exists if skill_dir.exists() and not overwrite: diff --git a/src/fastmcp/utilities/token_cache.py b/src/fastmcp/utilities/token_cache.py new file mode 100644 index 000000000..9446090d3 --- /dev/null +++ b/src/fastmcp/utilities/token_cache.py @@ -0,0 +1,173 @@ +"""In-memory cache for token verification results. + +Provides a generic TTL-based cache for ``AccessToken`` objects, designed to +reduce repeated network calls during opaque-token verification. Only +*successful* verifications should be cached; errors and failures must be +retried on every request. + +Example: + ```python + from fastmcp.utilities.token_cache import TokenCache + + cache = TokenCache(ttl_seconds=300, max_size=10000) + + # On cache miss, call the upstream verifier and store the result. + hit, token = cache.get(raw_token) + if not hit: + token = await _call_upstream(raw_token) + if token is not None: + cache.set(raw_token, token) + ``` +""" + +from __future__ import annotations + +import hashlib +import time +from dataclasses import dataclass + +from fastmcp.server.auth.auth import AccessToken +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + +DEFAULT_MAX_CACHE_SIZE = 10_000 +_CLEANUP_INTERVAL = 60 # seconds between periodic sweeps + + +@dataclass +class _CacheEntry: + """A cached token result with its absolute expiration timestamp.""" + + result: AccessToken + expires_at: float + + +class TokenCache: + """TTL-based in-memory cache for ``AccessToken`` objects. + + Features: + - SHA-256 hashed cache keys (fixed size, regardless of token length). + - Per-entry TTL that respects both the configured ``ttl_seconds`` and the + token's own ``expires_at`` claim (whichever is sooner). + - Bounded size with FIFO eviction when the cache is full. + - Periodic cleanup of expired entries to prevent unbounded growth. + - Defensive deep copies on both store and retrieve to prevent + callers from mutating cached values. + + Caching is disabled when ``ttl_seconds`` is ``None`` or ``0``, or + when ``max_size`` is ``0``. Negative values raise ``ValueError``. + """ + + def __init__( + self, + *, + ttl_seconds: int | None = None, + max_size: int | None = None, + ) -> None: + """Initialise the cache. + + Args: + ttl_seconds: How long cached entries remain valid, in seconds. + ``None`` or ``0`` disables caching entirely. + max_size: Upper bound on the number of entries. When the limit is + reached, expired entries are swept first; if still full the + oldest entry is evicted. Defaults to 10 000. + """ + if ttl_seconds is not None and ttl_seconds < 0: + raise ValueError( + f"cache_ttl_seconds must be non-negative, got {ttl_seconds}" + ) + if max_size is not None and max_size < 0: + raise ValueError(f"max_cache_size must be non-negative, got {max_size}") + self._ttl = ttl_seconds or 0 + self._max_size = max_size if max_size is not None else DEFAULT_MAX_CACHE_SIZE + self._entries: dict[str, _CacheEntry] = {} + self._last_cleanup = time.monotonic() + + @property + def enabled(self) -> bool: + """Return whether caching is active.""" + return self._ttl > 0 and self._max_size > 0 + + # -- public API ---------------------------------------------------------- + + def get(self, token: str) -> tuple[bool, AccessToken | None]: + """Look up a cached verification result. + + Returns: + ``(True, AccessToken)`` on a cache hit, ``(False, None)`` on a miss + or when caching is disabled. The returned ``AccessToken`` is a deep + copy that is safe to mutate. + """ + if not self.enabled: + return (False, None) + + cache_key = self._hash_token(token) + entry = self._entries.get(cache_key) + + if entry is None: + return (False, None) + + if entry.expires_at < time.time(): + del self._entries[cache_key] + return (False, None) + + return (True, entry.result.model_copy(deep=True)) + + def set(self, token: str, result: AccessToken) -> None: + """Store a *successful* verification result. + + Only successful verifications should be cached. Failures (inactive + tokens, missing scopes, HTTP errors, timeouts) must **not** be cached + so that transient problems do not produce sticky false negatives. + """ + if not self.enabled: + return + + cache_key = self._hash_token(token) + + self._maybe_cleanup() + if cache_key not in self._entries: + self._enforce_size_limit() + + expires_at = time.time() + self._ttl + if result.expires_at: + expires_at = min(expires_at, float(result.expires_at)) + + self._entries[cache_key] = _CacheEntry( + result=result.model_copy(deep=True), + expires_at=expires_at, + ) + + # -- internals ----------------------------------------------------------- + + @staticmethod + def _hash_token(token: str) -> str: + """Return the SHA-256 hex digest of *token*.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + def _cleanup_expired(self) -> None: + """Remove all entries whose TTL has elapsed.""" + now = time.time() + expired = [k for k, v in self._entries.items() if v.expires_at < now] + for key in expired: + del self._entries[key] + if expired: + logger.debug("Cleaned up %d expired cache entries", len(expired)) + + def _maybe_cleanup(self) -> None: + """Run ``_cleanup_expired`` at most once per cleanup interval.""" + now = time.monotonic() + if now - self._last_cleanup > _CLEANUP_INTERVAL: + self._cleanup_expired() + self._last_cleanup = now + + def _enforce_size_limit(self) -> None: + """Ensure there is room for at least one new entry.""" + if len(self._entries) < self._max_size: + return + self._cleanup_expired() + if len(self._entries) >= self._max_size: + oldest_key = next(iter(self._entries)) + del self._entries[oldest_key] diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index 91377df45..9980d7e62 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -88,12 +88,14 @@ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]: globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute] name = actual_func.__name__ # ty: ignore[unresolved-attribute] defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute] + kwdefaults = actual_func.__kwdefaults__ # ty: ignore[unresolved-attribute] closure = actual_func.__closure__ # ty: ignore[unresolved-attribute] else: code = cls.__code__ globals_dict = cls.__globals__ name = cls.__name__ defaults = cls.__defaults__ + kwdefaults = cls.__kwdefaults__ closure = cls.__closure__ new_func = types.FunctionType( @@ -107,6 +109,7 @@ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]: new_func.__module__ = cls.__module__ new_func.__qualname__ = getattr(cls, "__qualname__", cls.__name__) new_func.__annotations__ = processed_hints + new_func.__kwdefaults__ = kwdefaults if inspect.ismethod(cls): new_method = types.MethodType(new_func, cls.__self__) @@ -224,7 +227,7 @@ def create_function_without_params( new_func.__module__ = fn.__module__ new_func.__qualname__ = getattr(fn, "__qualname__", fn.__name__) # ty: ignore[unresolved-attribute] new_func.__annotations__ = new_annotations - new_func.__signature__ = new_sig # type: ignore[attr-defined] + new_func.__signature__ = new_sig # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] if inspect.ismethod(fn): return types.MethodType(new_func, fn.__self__) diff --git a/tests/apps/__init__.py b/tests/apps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/apps/test_approval.py b/tests/apps/test_approval.py new file mode 100644 index 000000000..d06128612 --- /dev/null +++ b/tests/apps/test_approval.py @@ -0,0 +1,56 @@ +"""Tests for the Approval provider.""" + +from fastmcp import FastMCP +from fastmcp.apps.approval import Approval + + +class TestApprovalProvider: + async def test_request_approval_returns_structured_content(self): + server = FastMCP("test", providers=[Approval()]) + + result = await server.call_tool( + "request_approval", + {"summary": "Delete 47 files"}, + ) + assert result.structured_content is not None + + async def test_request_approval_with_details(self): + server = FastMCP("test", providers=[Approval()]) + + result = await server.call_tool( + "request_approval", + {"summary": "Deploy to prod", "details": "Version 3.2.0"}, + ) + assert result.structured_content is not None + + async def test_tool_visible_to_model(self): + server = FastMCP("test", providers=[Approval()]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "request_approval" in tool_names + + async def test_custom_name(self): + server = FastMCP("test", providers=[Approval(name="Gate")]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "request_approval" in tool_names + + async def test_custom_button_text(self): + server = FastMCP( + "test", + providers=[ + Approval( + approve_text="Ship it", + reject_text="Nope", + title="Deploy Gate", + ) + ], + ) + + result = await server.call_tool( + "request_approval", + {"summary": "Deploy v3.2"}, + ) + assert result.structured_content is not None diff --git a/tests/apps/test_choice.py b/tests/apps/test_choice.py new file mode 100644 index 000000000..2de7b0a89 --- /dev/null +++ b/tests/apps/test_choice.py @@ -0,0 +1,50 @@ +"""Tests for the Choice provider.""" + +from fastmcp import FastMCP +from fastmcp.apps.choice import Choice + + +class TestChoiceProvider: + async def test_choose_returns_structured_content(self): + server = FastMCP("test", providers=[Choice()]) + + result = await server.call_tool( + "choose", + {"prompt": "Pick one", "options": ["A", "B", "C"]}, + ) + assert result.structured_content is not None + + async def test_tool_visible_to_model(self): + server = FastMCP("test", providers=[Choice()]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "choose" in tool_names + + async def test_custom_name(self): + server = FastMCP("test", providers=[Choice(name="Picker")]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "choose" in tool_names + + async def test_custom_title(self): + server = FastMCP("test", providers=[Choice(title="Select Strategy")]) + + result = await server.call_tool( + "choose", + {"prompt": "How?", "options": ["Fast", "Slow"]}, + ) + assert result.structured_content is not None + + async def test_many_options(self): + server = FastMCP("test", providers=[Choice()]) + + result = await server.call_tool( + "choose", + { + "prompt": "Pick a color", + "options": ["Red", "Blue", "Green", "Yellow", "Purple"], + }, + ) + assert result.structured_content is not None diff --git a/tests/apps/test_file_upload.py b/tests/apps/test_file_upload.py new file mode 100644 index 000000000..c8bd7470e --- /dev/null +++ b/tests/apps/test_file_upload.py @@ -0,0 +1,174 @@ +"""Tests for the FileUpload provider.""" + +import base64 + +import pytest + +from fastmcp import FastMCP +from fastmcp.apps.file_upload import FileUpload + + +def _make_file( + name: str = "test.txt", + content: str = "hello world", + mime_type: str = "text/plain", +) -> dict: + data = base64.b64encode(content.encode()).decode() + return { + "name": name, + "size": len(content), + "type": mime_type, + "data": data, + } + + +class TestFileUploadProvider: + async def test_basic_store_and_list(self): + server = FastMCP("test", providers=[FileUpload()]) + files = [_make_file()] + + result = await server.call_tool("Files___store_files", {"files": files}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "test.txt" in text + + result = await server.call_tool("list_files", {}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "test.txt" in text + + async def test_read_text_file(self): + server = FastMCP("test", providers=[FileUpload()]) + files = [_make_file(content="DON'T PANIC")] + + await server.call_tool("Files___store_files", {"files": files}) + + result = await server.call_tool("read_file", {"name": "test.txt"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "DON'T PANIC" in text + + async def test_read_binary_file(self): + server = FastMCP("test", providers=[FileUpload()]) + data = base64.b64encode(b"\x00\x01\x02\xff").decode() + files = [{"name": "image.png", "size": 4, "type": "image/png", "data": data}] + + await server.call_tool("Files___store_files", {"files": files}) + + result = await server.call_tool("read_file", {"name": "image.png"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "content_base64" in text + + async def test_read_missing_file_raises(self): + server = FastMCP("test", providers=[FileUpload()]) + + with pytest.raises(Exception, match="not found"): + await server.call_tool("read_file", {"name": "nope.txt"}) + + async def test_multiple_files(self): + server = FastMCP("test", providers=[FileUpload()]) + files = [ + _make_file("a.txt", "aaa"), + _make_file("b.txt", "bbb"), + ] + + await server.call_tool("Files___store_files", {"files": files}) + + result = await server.call_tool("list_files", {}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "a.txt" in text + assert "b.txt" in text + + async def test_overwrite_file(self): + server = FastMCP("test", providers=[FileUpload()]) + + await server.call_tool( + "Files___store_files", + {"files": [_make_file(content="version 1")]}, + ) + await server.call_tool( + "Files___store_files", + {"files": [_make_file(content="version 2")]}, + ) + + result = await server.call_tool("read_file", {"name": "test.txt"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "version 2" in text + + async def test_custom_name(self): + server = FastMCP("test", providers=[FileUpload(name="Uploads")]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "file_manager" in tool_names + + # Routing uses the custom name + files = [_make_file()] + result = await server.call_tool("Uploads___store_files", {"files": files}) + 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): + server = FastMCP("test", providers=[FileUpload()]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + + 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 + + async def test_max_file_size_enforced_server_side(self): + server = FastMCP("test", providers=[FileUpload(max_file_size=100)]) + big_file = _make_file(content="x" * 200) + + with pytest.raises(Exception, match="exceeds max size"): + await server.call_tool("Files___store_files", {"files": [big_file]}) + + +class TestFileUploadSubclass: + async def test_custom_storage(self): + """Subclassing lets users provide their own persistence.""" + stored: dict[str, dict] = {} + + class MemoryUpload(FileUpload): + def on_store(self, files: list[dict], ctx) -> list[dict]: + for f in files: + stored[f["name"]] = f + return [ + { + "name": f["name"], + "type": f["type"], + "size": f["size"], + "size_display": "?", + "uploaded_at": "now", + } + for f in files + ] + + def on_list(self, ctx) -> list[dict]: + return [ + { + "name": f["name"], + "type": f["type"], + "size": f["size"], + "size_display": "?", + "uploaded_at": "now", + } + for f in stored.values() + ] + + def on_read(self, name: str, ctx) -> dict: + if name not in stored: + raise ValueError(f"Not found: {name}") + f = stored[name] + return {"name": f["name"], "content": "custom read"} + + server = FastMCP("test", providers=[MemoryUpload()]) + files = [_make_file()] + + await server.call_tool("Files___store_files", {"files": files}) + + assert "test.txt" in stored + + result = await server.call_tool("read_file", {"name": "test.txt"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "custom read" in text diff --git a/tests/apps/test_form.py b/tests/apps/test_form.py new file mode 100644 index 000000000..d4db5fcd2 --- /dev/null +++ b/tests/apps/test_form.py @@ -0,0 +1,101 @@ +"""Tests for the FormInput provider.""" + +import json + +import pydantic + +from fastmcp import FastMCP +from fastmcp.apps.form import FormInput + + +class Contact(pydantic.BaseModel): + name: str + email: str + phone: str | None = None + + +class TestFormInputProvider: + async def test_collect_returns_structured_content(self): + server = FastMCP("test", providers=[FormInput(model=Contact)]) + + result = await server.call_tool( + "collect_contact", + {"prompt": "Enter your details"}, + ) + assert result.structured_content is not None + + async def test_tool_name_derived_from_model(self): + server = FastMCP("test", providers=[FormInput(model=Contact)]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "collect_contact" in tool_names + + async def test_custom_tool_name(self): + server = FastMCP( + "test", + providers=[FormInput(model=Contact, tool_name="new_contact")], + ) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "new_contact" in tool_names + + async def test_submit_validates_and_returns_json(self): + server = FastMCP("test", providers=[FormInput(model=Contact)]) + + result = await server.call_tool( + "Contact___submit_form", + {"data": {"name": "Alice", "email": "alice@example.com"}}, + ) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + parsed = json.loads(text) + assert parsed["name"] == "Alice" + assert parsed["email"] == "alice@example.com" + assert parsed["phone"] is None + + async def test_submit_with_callback(self): + saved: list[Contact] = [] + + def on_submit(contact: Contact) -> str: + saved.append(contact) + return f"Saved {contact.name}" + + server = FastMCP( + "test", + providers=[FormInput(model=Contact, on_submit=on_submit)], + ) + + result = await server.call_tool( + "Contact___submit_form", + {"data": {"name": "Bob", "email": "bob@example.com"}}, + ) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "Saved Bob" in text + assert len(saved) == 1 + assert saved[0].name == "Bob" + + async def test_backend_tool_hidden(self): + server = FastMCP("test", providers=[FormInput(model=Contact)]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "_submit_form" not in tool_names + + async def test_multiple_models(self): + class Address(pydantic.BaseModel): + street: str + city: str + + server = FastMCP( + "test", + providers=[ + FormInput(model=Contact), + FormInput(model=Address), + ], + ) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "collect_contact" in tool_names + assert "collect_address" in tool_names diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index eecc4300c..7100683bc 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -14,7 +14,7 @@ class TestMainCLI: """Test that the main app is properly configured.""" # app.name is a tuple in cyclopts assert "fastmcp" in app.name - assert "FastMCP 2.0" in app.help + assert "FastMCP" in app.help # Just check that version exists, not the specific value assert hasattr(app, "version") @@ -50,7 +50,7 @@ class TestVersionCommand: """Test that the version command parses arguments correctly.""" command, bound, _ = app.parse_args(["version"]) assert callable(command) - assert command.__name__ == "version" # type: ignore[attr-defined] + assert command.__name__ == "version" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] # Default arguments aren't included in bound.arguments assert bound.arguments == {} @@ -58,7 +58,7 @@ class TestVersionCommand: """Test that the version command parses --copy flag correctly.""" command, bound, _ = app.parse_args(["version", "--copy"]) assert callable(command) - assert command.__name__ == "version" # type: ignore[attr-defined] + assert command.__name__ == "version" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] assert bound.arguments == {"copy": True} @patch("fastmcp.cli.cli.pyperclip.copy") @@ -440,6 +440,23 @@ class TestWindowsSpecific: assert result == "npx.exe" assert mock_run.call_count == 2 + @patch("subprocess.run") + def test_get_npx_command_windows_cmd_missing(self, mock_run): + """Test npx command detection continues when npx.cmd is missing.""" + from fastmcp.cli.cli import _get_npx_command + + with patch("sys.platform", "win32"): + # Missing npx.cmd should not abort detection + mock_run.side_effect = [ + FileNotFoundError("npx.cmd not found"), + Mock(returncode=0), + ] + + result = _get_npx_command() + + assert result == "npx.exe" + assert mock_run.call_count == 2 + @patch("subprocess.run") def test_get_npx_command_windows_fallback(self, mock_run): """Test npx command detection on Windows with plain npx.""" diff --git a/tests/cli/test_client_commands.py b/tests/cli/test_client_commands.py index 1add45ba2..a0d0745d7 100644 --- a/tests/cli/test_client_commands.py +++ b/tests/cli/test_client_commands.py @@ -16,6 +16,7 @@ from fastmcp.cli.client import ( _build_stdio_from_command, _format_call_result_text, _is_http_target, + _sanitize_untrusted_text, call_command, coerce_value, format_tool_signature, @@ -555,3 +556,27 @@ class TestFormatCallResult: _format_call_result_text(result) captured = capsys.readouterr() assert "value" in captured.out + + def test_escapes_rich_markup_and_control_chars( + self, capsys: pytest.CaptureFixture[str] + ): + result = CallToolResult( + content=[mcp.types.TextContent(type="text", text="[red]x[/red]\x1b[2J")], + structured_content=None, + meta=None, + data=None, + is_error=False, + ) + + _format_call_result_text(result) + captured = capsys.readouterr() + assert "[red]x[/red]" in captured.out + assert "\\x1b" in captured.out + assert "\x1b" not in captured.out + + +class TestSanitizeUntrustedText: + def test_sanitize_untrusted_text(self): + value = "[bold]hello[/bold]\x07" + sanitized = _sanitize_untrusted_text(value) + assert sanitized == "\\[bold]hello\\[/bold]\\x07" diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py index 476b678ef..3996aee34 100644 --- a/tests/cli/test_cursor.py +++ b/tests/cli/test_cursor.py @@ -9,6 +9,7 @@ from fastmcp.cli.install.cursor import ( cursor_command, generate_cursor_deeplink, install_cursor, + install_cursor_workspace, open_deeplink, ) from fastmcp.mcp_config import StdioMCPServer @@ -356,6 +357,20 @@ class TestInstallCursor: # Verify failure message was printed mock_print.assert_called() + def test_install_cursor_workspace_path_is_file(self, tmp_path): + """Test that passing a file as workspace_path returns False.""" + file_path = tmp_path / "somefile.txt" + file_path.write_text("hello") + + result = install_cursor_workspace( + file=Path("/path/to/server.py"), + server_object=None, + name="test-server", + workspace_path=file_path, + ) + + assert result is False + def test_install_cursor_deduplicate_packages(self): """Test that duplicate packages are deduplicated.""" with patch("fastmcp.cli.install.cursor.open_deeplink") as mock_open: diff --git a/tests/cli/test_install.py b/tests/cli/test_install.py index 81fde3c1c..9dca455fa 100644 --- a/tests/cli/test_install.py +++ b/tests/cli/test_install.py @@ -1,6 +1,9 @@ from pathlib import Path +import pytest + from fastmcp.cli.install import install_app +from fastmcp.cli.install.shared import validate_server_name from fastmcp.cli.install.stdio import install_stdio @@ -142,6 +145,20 @@ class TestClaudeDesktopInstall: assert bound.arguments["project"] == Path("/my/project") assert bound.arguments["with_requirements"] == Path("reqs.txt") + def test_claude_desktop_with_config_path(self): + """Test claude-desktop install with custom config path.""" + command, bound, _ = install_app.parse_args( + ["claude-desktop", "server.py", "--config-path", "/custom/path/Claude"] + ) + + assert bound.arguments["config_path"] == Path("/custom/path/Claude") + + def test_claude_desktop_without_config_path(self): + """Test claude-desktop install without config path defaults to None.""" + command, bound, _ = install_app.parse_args(["claude-desktop", "server.py"]) + + assert bound.arguments.get("config_path") is None + class TestCursorInstall: """Test cursor install command.""" @@ -441,3 +458,37 @@ class TestInstallCommandParsing: command, bound, _ = install_app.parse_args(cmd_args) assert command is not None assert str(bound.arguments["project"]) == str(Path("/path/to/project")) + + +class TestServerNameValidation: + """Test server name validation rejects shell metacharacters.""" + + @pytest.mark.parametrize( + "name", + [ + "my-server", + "my_server", + "My Server", + "server.v2", + "test123", + ], + ) + def test_valid_names(self, name: str): + assert validate_server_name(name) == name + + @pytest.mark.parametrize( + "name", + [ + "test&calc", + "test|whoami", + "test;ls", + "test$(id)", + "test`id`", + 'test"quoted', + "test>file", + "test list[int]: + return [1, 2, 3] + + client = Client(transport=FastMCPTransport(server)) + async with client: + 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}} + + +async def test_client_does_not_unwrap_dict_result(): + """Client should not unwrap dict results that are not wrapped.""" + server = FastMCP() + + @server.tool + def dict_tool() -> dict[str, int]: + return {"a": 1} + + client = Client(transport=FastMCPTransport(server)) + async with client: + result = await client.call_tool("dict_tool", {}) + assert result.structured_content == {"a": 1} + assert result.data == {"a": 1} + assert result.meta is None diff --git a/tests/client/client/test_timeout.py b/tests/client/client/test_timeout.py index 0b3b0fd3a..5106e7cb6 100644 --- a/tests/client/client/test_timeout.py +++ b/tests/client/client/test_timeout.py @@ -1,7 +1,5 @@ """Client timeout tests.""" -import sys - import pytest from mcp import McpError @@ -36,15 +34,11 @@ class TestTimeout: with pytest.raises(McpError): await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) - @pytest.mark.skipif( - sys.platform == "win32", - reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.", - ) async def test_timeout_tool_call_overrides_client_timeout_even_if_lower( self, fastmcp_server: FastMCP ): async with Client( transport=FastMCPTransport(fastmcp_server), - timeout=0.01, + timeout=0.1, ) as client: - await client.call_tool("sleep", {"seconds": 0.1}, timeout=2) + await client.call_tool("sleep", {"seconds": 0.5}, timeout=2) diff --git a/tests/client/sampling/handlers/test_anthropic_handler.py b/tests/client/sampling/handlers/test_anthropic_handler.py index 57a464ada..5910eb92b 100644 --- a/tests/client/sampling/handlers/test_anthropic_handler.py +++ b/tests/client/sampling/handlers/test_anthropic_handler.py @@ -1,19 +1,26 @@ +from typing import Any from unittest.mock import MagicMock import pytest from anthropic import AsyncAnthropic from anthropic.types import Message, TextBlock, ToolUseBlock, Usage from mcp.types import ( + AudioContent, CreateMessageResult, CreateMessageResultWithTools, + ImageContent, ModelHint, ModelPreferences, SamplingMessage, TextContent, + ToolResultContent, ToolUseContent, ) -from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler +from fastmcp.client.sampling.handlers.anthropic import ( + AnthropicSamplingHandler, + _image_content_to_anthropic_block, +) def test_convert_sampling_messages_to_anthropic_messages(): @@ -34,15 +41,137 @@ def test_convert_sampling_messages_to_anthropic_messages(): ] -def test_convert_to_anthropic_messages_raises_on_non_text(): - from fastmcp.utilities.types import Image +def test_image_content_to_anthropic_block(): + block = _image_content_to_anthropic_block( + ImageContent(type="image", data="YWJj", mimeType="image/png") + ) - with pytest.raises(ValueError): + assert block == { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "YWJj", + }, + } + + +def test_image_content_unsupported_mime_type_raises(): + with pytest.raises(ValueError, match="Unsupported image MIME type"): + _image_content_to_anthropic_block( + ImageContent(type="image", data="YWJj", mimeType="image/bmp") + ) + + +def test_convert_single_image_content_to_anthropic_message(): + msgs = AnthropicSamplingHandler._convert_to_anthropic_messages( + messages=[ + SamplingMessage( + role="user", + content=ImageContent(type="image", data="YWJj", mimeType="image/png"), + ) + ], + ) + + assert len(msgs) == 1 + assert msgs[0] == { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "YWJj", + }, + } + ], + } + + +def test_convert_single_audio_content_raises(): + with pytest.raises(ValueError, match="AudioContent is not supported"): AnthropicSamplingHandler._convert_to_anthropic_messages( messages=[ SamplingMessage( role="user", - content=Image(data=b"abc").to_image_content(), + content=AudioContent( + type="audio", data="YWJj", mimeType="audio/wav" + ), + ) + ], + ) + + +def test_convert_list_content_with_image_and_text(): + msgs = AnthropicSamplingHandler._convert_to_anthropic_messages( + messages=[ + SamplingMessage( + role="user", + content=[ + TextContent(type="text", text="Describe this image"), + ImageContent(type="image", data="YWJj", mimeType="image/jpeg"), + ], + ) + ], + ) + + assert len(msgs) == 1 + assert msgs[0] == { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": "YWJj", + }, + }, + ], + } + + +def test_convert_list_content_with_audio_raises(): + with pytest.raises(ValueError, match="AudioContent is not supported"): + AnthropicSamplingHandler._convert_to_anthropic_messages( + messages=[ + SamplingMessage( + role="user", + content=[ + TextContent(type="text", text="Listen to this"), + AudioContent(type="audio", data="YWJj", mimeType="audio/wav"), + ], + ) + ], + ) + + +def test_convert_image_in_assistant_message_raises(): + with pytest.raises(ValueError, match="ImageContent is only supported in user"): + AnthropicSamplingHandler._convert_to_anthropic_messages( + messages=[ + SamplingMessage( + role="assistant", + content=ImageContent( + type="image", data="YWJj", mimeType="image/png" + ), + ) + ], + ) + + +def test_convert_list_image_in_assistant_message_raises(): + with pytest.raises(ValueError, match="ImageContent is only supported in user"): + AnthropicSamplingHandler._convert_to_anthropic_messages( + messages=[ + SamplingMessage( + role="assistant", + content=[ + TextContent(type="text", text="Here's the image"), + ImageContent(type="image", data="YWJj", mimeType="image/png"), + ], ) ], ) @@ -61,7 +190,7 @@ def test_convert_to_anthropic_messages_raises_on_non_text(): (["unknown-model"], "fallback-model"), ], ) -def test_select_model_from_preferences(prefs, expected): +def test_select_model_from_preferences(prefs: Any, expected: str) -> None: mock_client = MagicMock(spec=AsyncAnthropic) handler = AnthropicSamplingHandler( default_model="fallback-model", client=mock_client @@ -220,8 +349,6 @@ def test_convert_messages_with_tool_use_content(): def test_convert_messages_with_tool_result_content(): """Test converting messages that include tool result content from user.""" - from mcp.types import ToolResultContent - msgs = AnthropicSamplingHandler._convert_to_anthropic_messages( messages=[ SamplingMessage( diff --git a/tests/client/sampling/handlers/test_google_genai_handler.py b/tests/client/sampling/handlers/test_google_genai_handler.py index 92403461e..7eb0da188 100644 --- a/tests/client/sampling/handlers/test_google_genai_handler.py +++ b/tests/client/sampling/handlers/test_google_genai_handler.py @@ -1,3 +1,4 @@ +import base64 from unittest.mock import MagicMock import pytest @@ -14,9 +15,12 @@ try: UserContent, ) from mcp.types import ( + AudioContent, CreateMessageResult, + ImageContent, ModelHint, ModelPreferences, + SamplingMessage, TextContent, ToolChoice, ToolResultContent, @@ -42,8 +46,6 @@ pytestmark = pytest.mark.skipif( def test_convert_sampling_messages_to_google_genai_content(): - from mcp.types import SamplingMessage, TextContent - msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( @@ -62,20 +64,98 @@ def test_convert_sampling_messages_to_google_genai_content(): assert msgs[1].parts[0].text == "ok" -def test_convert_to_google_genai_messages_raises_on_non_text(): - from mcp.types import SamplingMessage +def test_convert_single_image_content_to_google_genai(): + part = _sampling_content_to_google_genai_part( + ImageContent(type="image", data="YWJj", mimeType="image/png") + ) - from fastmcp.utilities.types import Image + assert part.inline_data is not None + assert part.inline_data.data == base64.b64decode("YWJj") + assert part.inline_data.mime_type == "image/png" - with pytest.raises(ValueError): - _convert_messages_to_google_genai_content( - messages=[ - SamplingMessage( - role="user", - content=Image(data=b"abc").to_image_content(), - ) - ], - ) + +def test_convert_single_audio_content_to_google_genai(): + part = _sampling_content_to_google_genai_part( + AudioContent(type="audio", data="YWJj", mimeType="audio/wav") + ) + + assert part.inline_data is not None + assert part.inline_data.data == base64.b64decode("YWJj") + assert part.inline_data.mime_type == "audio/wav" + + +def test_convert_image_message_to_google_genai_content(): + msgs = _convert_messages_to_google_genai_content( + messages=[ + SamplingMessage( + role="user", + content=ImageContent(type="image", data="YWJj", mimeType="image/jpeg"), + ) + ], + ) + + assert len(msgs) == 1 + assert isinstance(msgs[0], UserContent) + assert msgs[0].parts[0].inline_data is not None + assert msgs[0].parts[0].inline_data.mime_type == "image/jpeg" + + +def test_convert_audio_message_to_google_genai_content(): + msgs = _convert_messages_to_google_genai_content( + messages=[ + SamplingMessage( + role="user", + content=AudioContent(type="audio", data="YWJj", mimeType="audio/mp3"), + ) + ], + ) + + assert len(msgs) == 1 + assert isinstance(msgs[0], UserContent) + assert msgs[0].parts[0].inline_data is not None + assert msgs[0].parts[0].inline_data.mime_type == "audio/mp3" + + +def test_convert_list_content_with_image_and_text(): + msgs = _convert_messages_to_google_genai_content( + messages=[ + SamplingMessage( + role="user", + content=[ + TextContent(type="text", text="What is in this image?"), + ImageContent(type="image", data="YWJj", mimeType="image/png"), + ], + ) + ], + ) + + assert len(msgs) == 1 + assert isinstance(msgs[0], UserContent) + assert len(msgs[0].parts) == 2 + assert msgs[0].parts[0].text == "What is in this image?" + assert msgs[0].parts[1].inline_data is not None + assert msgs[0].parts[1].inline_data.mime_type == "image/png" + + +def test_convert_list_content_with_audio_and_text(): + msgs = _convert_messages_to_google_genai_content( + messages=[ + SamplingMessage( + role="user", + content=[ + TextContent(type="text", text="Transcribe this audio"), + AudioContent(type="audio", data="YWJj", mimeType="audio/wav"), + ], + ) + ], + ) + + assert len(msgs) == 1 + assert isinstance(msgs[0], UserContent) + assert len(msgs[0].parts) == 2 + assert msgs[0].parts[0].text == "Transcribe this audio" + assert msgs[0].parts[1].inline_data is not None + assert msgs[0].parts[1].inline_data.mime_type == "audio/wav" def test_get_model(): @@ -207,8 +287,6 @@ def test_sampling_content_to_google_genai_part_tool_result_no_underscore(): def test_convert_messages_with_tool_use(): """Test converting messages containing ToolUseContent.""" - from mcp.types import SamplingMessage - msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( @@ -236,8 +314,6 @@ def test_convert_messages_with_tool_use(): def test_convert_messages_with_tool_result(): """Test converting messages containing ToolResultContent.""" - from mcp.types import SamplingMessage - msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( @@ -245,7 +321,7 @@ def test_convert_messages_with_tool_result(): content=ToolResultContent( type="tool_result", toolUseId="get_weather_123", - content=[TextContent(type="text", text="Sunny, 72°F")], + content=[TextContent(type="text", text="Sunny, 72F")], ), ), ], @@ -259,8 +335,6 @@ def test_convert_messages_with_tool_result(): def test_convert_messages_with_multiple_content_blocks(): """Test converting messages with multiple content blocks (list content).""" - from mcp.types import SamplingMessage - msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( diff --git a/tests/client/sampling/handlers/test_openai_handler.py b/tests/client/sampling/handlers/test_openai_handler.py index 29f12d749..e80ba3292 100644 --- a/tests/client/sampling/handlers/test_openai_handler.py +++ b/tests/client/sampling/handlers/test_openai_handler.py @@ -1,25 +1,36 @@ +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from mcp.types import ( + AudioContent, CreateMessageRequestParams, CreateMessageResult, + ImageContent, ModelHint, ModelPreferences, SamplingMessage, TextContent, + ToolUseContent, ) from openai import AsyncOpenAI from openai.types.chat import ( ChatCompletion, ChatCompletionAssistantMessageParam, + ChatCompletionContentPartImageParam, + ChatCompletionContentPartInputAudioParam, + ChatCompletionContentPartTextParam, ChatCompletionMessage, ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ) from openai.types.chat.chat_completion import Choice -from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler +from fastmcp.client.sampling.handlers.openai import ( + OpenAISamplingHandler, + _audio_content_to_openai_part, + _image_content_to_openai_part, +) def test_convert_sampling_messages_to_openai_messages(): @@ -42,16 +53,189 @@ def test_convert_sampling_messages_to_openai_messages(): ] -def test_convert_to_openai_messages_raises_on_non_text(): - from fastmcp.utilities.types import Image +def test_image_content_to_openai_part(): + part = _image_content_to_openai_part( + ImageContent(type="image", data="YWJj", mimeType="image/png") + ) - with pytest.raises(ValueError): + assert part == ChatCompletionContentPartImageParam( + type="image_url", + image_url={"url": "data:image/png;base64,YWJj"}, + ) + + +def test_audio_content_to_openai_part_wav(): + part = _audio_content_to_openai_part( + AudioContent(type="audio", data="YWJj", mimeType="audio/wav") + ) + + assert part == ChatCompletionContentPartInputAudioParam( + type="input_audio", + input_audio={"data": "YWJj", "format": "wav"}, + ) + + +def test_audio_content_to_openai_part_mp3(): + part = _audio_content_to_openai_part( + AudioContent(type="audio", data="YWJj", mimeType="audio/mpeg") + ) + + assert part["input_audio"]["format"] == "mp3" + + +def test_audio_content_to_openai_part_unsupported_raises(): + with pytest.raises(ValueError, match="Unsupported audio MIME type"): + _audio_content_to_openai_part( + AudioContent(type="audio", data="YWJj", mimeType="audio/ogg") + ) + + +def test_image_content_to_openai_part_unsupported_raises(): + with pytest.raises(ValueError, match="Unsupported image MIME type"): + _image_content_to_openai_part( + ImageContent(type="image", data="YWJj", mimeType="image/bmp") + ) + + +def test_convert_single_image_content_to_openai_message(): + msgs = OpenAISamplingHandler._convert_to_openai_messages( + system_prompt=None, + messages=[ + SamplingMessage( + role="user", + content=ImageContent(type="image", data="YWJj", mimeType="image/png"), + ) + ], + ) + + assert len(msgs) == 1 + assert msgs[0] == ChatCompletionUserMessageParam( + role="user", + content=[ + ChatCompletionContentPartImageParam( + type="image_url", + image_url={"url": "data:image/png;base64,YWJj"}, + ) + ], + ) + + +def test_convert_single_audio_content_to_openai_message(): + msgs = OpenAISamplingHandler._convert_to_openai_messages( + system_prompt=None, + messages=[ + SamplingMessage( + role="user", + content=AudioContent(type="audio", data="YWJj", mimeType="audio/wav"), + ) + ], + ) + + assert len(msgs) == 1 + assert msgs[0] == ChatCompletionUserMessageParam( + role="user", + content=[ + ChatCompletionContentPartInputAudioParam( + type="input_audio", + input_audio={"data": "YWJj", "format": "wav"}, + ) + ], + ) + + +def test_convert_list_content_with_image_and_text(): + msgs = OpenAISamplingHandler._convert_to_openai_messages( + system_prompt=None, + messages=[ + SamplingMessage( + role="user", + content=[ + TextContent(type="text", text="What is in this image?"), + ImageContent(type="image", data="YWJj", mimeType="image/jpeg"), + ], + ) + ], + ) + + assert len(msgs) == 1 + assert msgs[0] == ChatCompletionUserMessageParam( + role="user", + content=[ + ChatCompletionContentPartTextParam( + type="text", text="What is in this image?" + ), + ChatCompletionContentPartImageParam( + type="image_url", + image_url={"url": "data:image/jpeg;base64,YWJj"}, + ), + ], + ) + + +def test_convert_image_in_assistant_message_raises(): + with pytest.raises(ValueError, match="ImageContent is only supported in user"): OpenAISamplingHandler._convert_to_openai_messages( system_prompt=None, messages=[ SamplingMessage( - role="user", - content=Image(data=b"abc").to_image_content(), + role="assistant", + content=ImageContent( + type="image", data="YWJj", mimeType="image/png" + ), + ) + ], + ) + + +def test_convert_audio_in_assistant_message_raises(): + with pytest.raises(ValueError, match="AudioContent is only supported in user"): + OpenAISamplingHandler._convert_to_openai_messages( + system_prompt=None, + messages=[ + SamplingMessage( + role="assistant", + content=AudioContent( + type="audio", data="YWJj", mimeType="audio/wav" + ), + ) + ], + ) + + +def test_convert_list_image_in_assistant_message_raises(): + """Image/audio in an assistant list-content message should raise, not silently drop.""" + with pytest.raises(ValueError, match="only supported in user messages"): + OpenAISamplingHandler._convert_to_openai_messages( + system_prompt=None, + messages=[ + SamplingMessage( + role="assistant", + content=[ + TextContent(type="text", text="Here's the image"), + ImageContent(type="image", data="YWJj", mimeType="image/png"), + ], + ) + ], + ) + + +def test_convert_list_tool_calls_with_image_raises(): + """Image/audio alongside tool_calls in assistant list should raise.""" + with pytest.raises(ValueError, match="only supported in user messages"): + OpenAISamplingHandler._convert_to_openai_messages( + system_prompt=None, + messages=[ + SamplingMessage( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="my_tool", + input={"arg": "val"}, + ), + ImageContent(type="image", data="YWJj", mimeType="image/png"), + ], ) ], ) @@ -67,9 +251,9 @@ def test_convert_to_openai_messages_raises_on_non_text(): (["unknown-model"], "fallback-model"), ], ) -def test_select_model_from_preferences(prefs, expected): +def test_select_model_from_preferences(prefs: Any, expected: str) -> None: mock_client = MagicMock(spec=AsyncOpenAI) - handler = OpenAISamplingHandler(default_model="fallback-model", client=mock_client) # type: ignore[arg-type] + handler = OpenAISamplingHandler(default_model="fallback-model", client=mock_client) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] assert handler._select_model_from_preferences(prefs) == expected @@ -98,7 +282,7 @@ async def test_handler_passes_max_completion_tokens(): SamplingMessage(role="user", content=TextContent(type="text", text="hello")) ] params = CreateMessageRequestParams(messages=messages, maxTokens=300) - await handler(messages, params, context=None) # type: ignore[arg-type] + await handler(messages, params, context=None) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] call_kwargs = mock_client.chat.completions.create.call_args assert "max_completion_tokens" in call_kwargs.kwargs @@ -108,7 +292,7 @@ async def test_handler_passes_max_completion_tokens(): async def test_chat_completion_to_create_message_result(): mock_client = MagicMock(spec=AsyncOpenAI) - handler = OpenAISamplingHandler(default_model="fallback-model", client=mock_client) # type: ignore[arg-type] + handler = OpenAISamplingHandler(default_model="fallback-model", client=mock_client) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] mock_client.chat.completions.create.return_value = ChatCompletion( id="123", created=123, diff --git a/tests/client/tasks/conftest.py b/tests/client/tasks/conftest.py new file mode 100644 index 000000000..29d0c9a10 --- /dev/null +++ b/tests/client/tasks/conftest.py @@ -0,0 +1 @@ +"""Configuration for client task tests.""" diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py index ff00def14..7b8179f82 100644 --- a/tests/client/test_elicitation.py +++ b/tests/client/test_elicitation.py @@ -290,7 +290,7 @@ class TestScalarResponseTypes: @mcp.tool async def my_tool(context: Context) -> Literal["x", "y"]: # Literal types work at runtime but type checker doesn't recognize them in overloads - result = await context.elicit(message="", response_type=Literal["x", "y"]) # type: ignore[arg-type] + result = await context.elicit(message="", response_type=Literal["x", "y"]) # type: ignore[arg-type] # ty:ignore[no-matching-overload] assert isinstance(result, AcceptedElicitation) accepted = cast(AcceptedElicitation[Literal["x", "y"]], result) assert isinstance(accepted.data, str) diff --git a/tests/client/test_elicitation_enums.py b/tests/client/test_elicitation_enums.py index d67e2b4f1..6e8b1e7fd 100644 --- a/tests/client/test_elicitation_enums.py +++ b/tests/client/test_elicitation_enums.py @@ -200,7 +200,7 @@ async def test_list_list_multi_select_untitled(): if result.action == "accept": assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, list) - return ",".join(result.data) # type: ignore[no-matching-overload] + return ",".join(result.data) # type: ignore[no-matching-overload] # ty:ignore[no-matching-overload] return "declined" async def elicitation_handler(message, response_type, params, ctx): @@ -238,7 +238,7 @@ async def test_list_dict_multi_select_titled(): if result.action == "accept": assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, list) - return ",".join(result.data) # type: ignore[no-matching-overload] + return ",".join(result.data) # type: ignore[no-matching-overload] # ty:ignore[no-matching-overload] return "declined" async def elicitation_handler(message, response_type, params, ctx): diff --git a/tests/client/test_logs.py b/tests/client/test_logs.py index f04af4338..2952016bd 100644 --- a/tests/client/test_logs.py +++ b/tests/client/test_logs.py @@ -90,6 +90,99 @@ class TestClientLogs: assert caplog.records[1].levelname == "WARNING" +class TestSetLoggingLevel: + async def test_set_logging_level(self, fastmcp_server: FastMCP): + """Client can set the minimum log level and lower-level messages are suppressed.""" + log_handler = LogHandler() + async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: + await client.set_logging_level("warning") + await client.call_tool( + "echo_log", {"message": "debug msg", "level": "debug"} + ) + await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) + await client.call_tool( + "echo_log", {"message": "warning msg", "level": "warning"} + ) + await client.call_tool( + "echo_log", {"message": "error msg", "level": "error"} + ) + + assert len(log_handler.logs) == 2 + assert log_handler.logs[0].data["msg"] == "warning msg" + assert log_handler.logs[1].data["msg"] == "error msg" + + async def test_set_logging_level_debug_allows_all(self, fastmcp_server: FastMCP): + """Setting level to debug allows all messages through.""" + log_handler = LogHandler() + async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: + await client.set_logging_level("debug") + await client.call_tool( + "echo_log", {"message": "debug msg", "level": "debug"} + ) + await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) + + assert len(log_handler.logs) == 2 + + async def test_default_level_allows_all(self, fastmcp_server: FastMCP): + """Without calling set_logging_level, all messages are sent.""" + log_handler = LogHandler() + async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: + await client.call_tool( + "echo_log", {"message": "debug msg", "level": "debug"} + ) + await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) + + assert len(log_handler.logs) == 2 + + async def test_server_default_client_log_level(self): + """Server-wide client_log_level filters messages for all sessions.""" + mcp = FastMCP(client_log_level="error") + + @mcp.tool + async def echo_log( + message: str, context: Context, level: LoggingLevel | None = None + ) -> None: + await context.log(message=message, level=level) + + log_handler = LogHandler() + async with Client(mcp, log_handler=log_handler.handle_log) as client: + await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) + await client.call_tool( + "echo_log", {"message": "warning msg", "level": "warning"} + ) + await client.call_tool( + "echo_log", {"message": "error msg", "level": "error"} + ) + + assert len(log_handler.logs) == 1 + assert log_handler.logs[0].data["msg"] == "error msg" + + async def test_session_level_overrides_server_default(self): + """Per-session setLevel overrides the server's client_log_level.""" + mcp = FastMCP(client_log_level="error") + + @mcp.tool + async def echo_log( + message: str, context: Context, level: LoggingLevel | None = None + ) -> None: + await context.log(message=message, level=level) + + log_handler = LogHandler() + async with Client(mcp, log_handler=log_handler.handle_log) as client: + await client.set_logging_level("warning") + await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) + await client.call_tool( + "echo_log", {"message": "warning msg", "level": "warning"} + ) + await client.call_tool( + "echo_log", {"message": "error msg", "level": "error"} + ) + + assert len(log_handler.logs) == 2 + assert log_handler.logs[0].data["msg"] == "warning msg" + assert log_handler.logs[1].data["msg"] == "error msg" + + class TestDefaultLogHandler: """Tests for default_log_handler with data as any JSON-serializable type.""" @@ -131,7 +224,7 @@ class TestDefaultLogHandler: # Create log message with data as a string log_msg = LoggingMessageNotificationParams( - level=level, # type: ignore[arg-type] + level=level, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] logger="test.logger", data=msg, ) diff --git a/tests/client/test_oauth_callback_race.py b/tests/client/test_oauth_callback_race.py new file mode 100644 index 000000000..7ebcb4534 --- /dev/null +++ b/tests/client/test_oauth_callback_race.py @@ -0,0 +1,44 @@ +import anyio +import httpx + +from fastmcp.client.oauth_callback import ( + OAuthCallbackResult, + create_oauth_callback_server, +) +from fastmcp.utilities.http import find_available_port + + +async def test_oauth_callback_result_ignores_subsequent_callbacks(): + """Only the first callback should be captured in shared OAuth callback state.""" + port = find_available_port() + result = OAuthCallbackResult() + result_ready = anyio.Event() + server = create_oauth_callback_server( + port=port, + result_container=result, + result_ready=result_ready, + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(server.serve) + + await anyio.sleep(0.05) + + async with httpx.AsyncClient() as client: + first = await client.get( + f"http://127.0.0.1:{port}/callback?code=good&state=s1" + ) + assert first.status_code == 200 + + await result_ready.wait() + + second = await client.get( + f"http://127.0.0.1:{port}/callback?code=evil&state=s2" + ) + assert second.status_code == 200 + + assert result.error is None + assert result.code == "good" + assert result.state == "s1" + + tg.cancel_scope.cancel() diff --git a/tests/client/test_progress.py b/tests/client/test_progress.py index 63244df7c..946c5b65c 100644 --- a/tests/client/test_progress.py +++ b/tests/client/test_progress.py @@ -68,3 +68,9 @@ async def test_progress_handler_supplied_on_tool_call_overrides_default( await client.call_tool("progress_tool", {}, progress_handler=progress_handler) assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES + + +async def test_default_progress_handler_handles_zero_total() -> None: + from fastmcp.client.progress import default_progress_handler + + await default_progress_handler(progress=1, total=0, message="starting") diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 058d41ea2..3d1335ae5 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -62,6 +62,7 @@ class TestParallelCalls: assert len(errors) == 0 +@pytest.mark.timeout(15) class TestKeepAlive: # https://github.com/PrefectHQ/fastmcp/issues/581 @@ -255,6 +256,279 @@ class TestKeepAlive: pass +@pytest.mark.timeout(15) +class TestSubprocessCrashRecovery: + """Test that StdioTransport recovers after the subprocess crashes.""" + + # Use a short init_timeout so tests fail fast instead of hanging if + # stream-based dead-session detection is slow (e.g. on Windows where + # pipe cleanup can lag after process termination). + INIT_TIMEOUT = 3 + + @pytest.fixture + def stdio_script(self, tmp_path): + script = inspect.cleandoc(''' + import os + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def pid() -> int: + """Gets PID of server""" + return os.getpid() + + if __name__ == "__main__": + mcp.run() + ''') + script_file = tmp_path / "stdio.py" + script_file.write_text(script) + return script_file + + async def test_keep_alive_recovers_after_subprocess_crash(self, stdio_script): + """When keep_alive=True and the subprocess dies, the next connection should start a fresh subprocess.""" + transport = PythonStdioTransport(script_path=stdio_script) + client = Client(transport=transport, init_timeout=self.INIT_TIMEOUT) + assert transport.keep_alive is True + + # First connection: get the PID of the subprocess + async with client: + result1 = await client.call_tool("pid") + pid1: int = result1.data + + # Kill the subprocess to simulate a crash + psutil.Process(pid1).kill() + + # First attempt after crash fails — the stale session is + # detected and torn down so subsequent attempts succeed. + with pytest.raises(Exception): + async with client: + await client.call_tool("pid") + + # Next connection starts a fresh subprocess + async with client: + result2 = await client.call_tool("pid") + pid2: int = result2.data + + assert pid1 != pid2 + + async def test_keep_alive_false_recovers_after_subprocess_crash(self, stdio_script): + """When keep_alive=False, crash recovery works because disconnect() is always called.""" + client = Client( + transport=PythonStdioTransport(script_path=stdio_script, keep_alive=False), + init_timeout=self.INIT_TIMEOUT, + ) + + async with client: + result1 = await client.call_tool("pid") + pid1: int = result1.data + + # Process should already be dead (keep_alive=False), but kill to be sure + with pytest.raises(psutil.NoSuchProcess): + psutil.Process(pid1).kill() + + # Next connection should work fine + async with client: + result2 = await client.call_tool("pid") + pid2: int = result2.data + + assert pid1 != pid2 + + async def test_multiple_consecutive_crashes(self, stdio_script): + """Recovery works across multiple crash/reconnect cycles.""" + client = Client( + transport=PythonStdioTransport(script_path=stdio_script), + init_timeout=self.INIT_TIMEOUT, + ) + pids: list[int] = [] + + for _ in range(3): + async with client: + result = await client.call_tool("pid") + pid: int = result.data + pids.append(pid) + + # Kill the subprocess + psutil.Process(pid).kill() + + # Fail once to trigger cleanup + with pytest.raises(Exception): + async with client: + await client.call_tool("pid") + + # Each cycle should have started a new subprocess + assert len(set(pids)) == 3 + + async def test_crash_during_active_context(self, stdio_script): + """When subprocess dies while the client context is open, recovery works on the next attempt.""" + client = Client( + transport=PythonStdioTransport(script_path=stdio_script), + init_timeout=self.INIT_TIMEOUT, + ) + pid1: int = 0 + + with pytest.raises(Exception): + async with client: + result = await client.call_tool("pid") + pid1 = result.data + # Kill while the context is still open + psutil.Process(pid1).kill() + # This call hits the dead session + await client.call_tool("pid") + + assert pid1 != 0, "First call should have succeeded before the crash" + + # Recovery: next connection starts a fresh subprocess + async with client: + result = await client.call_tool("pid") + pid2: int = result.data + + assert pid1 != pid2 + + async def test_proxy_recovers_after_stdio_crash(self, stdio_script): + """A proxy server wrapping a stdio backend recovers after the backend crashes.""" + from fastmcp.server import create_proxy + + backend_client = Client( + transport=PythonStdioTransport(script_path=stdio_script), + init_timeout=self.INIT_TIMEOUT, + ) + proxy = create_proxy(target=backend_client, name="test-proxy") + + # First call works + result1 = await proxy.call_tool("pid") + pid1 = int(result1.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + # Kill the backend subprocess + psutil.Process(pid1).kill() + + # First call after crash fails + with pytest.raises(Exception): + await proxy.call_tool("pid") + + # Second call recovers with a new subprocess + result2 = await proxy.call_tool("pid") + pid2 = int(result2.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + assert pid1 != pid2 + + async def test_concurrent_requests_during_crash(self, stdio_script): + """Multiple concurrent callers fail cleanly when subprocess dies, then recovery works.""" + from fastmcp.server import create_proxy + + backend_client = Client( + transport=PythonStdioTransport(script_path=stdio_script), + init_timeout=self.INIT_TIMEOUT, + ) + proxy = create_proxy(target=backend_client, name="test-proxy") + + # First call to get the PID + result = await proxy.call_tool("pid") + pid1 = int(result.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + # Kill the subprocess + psutil.Process(pid1).kill() + + # Fire several concurrent requests — all should fail, none should hang + tasks = [proxy.call_tool("pid") for _ in range(5)] + results = await asyncio.gather(*tasks, return_exceptions=True) + + errors = [r for r in results if isinstance(r, Exception)] + assert len(errors) > 0 + + # Recovery: a subsequent request should succeed + result = await proxy.call_tool("pid") + pid2 = int(result.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert pid1 != pid2 + + async def test_clean_exit_recovers(self, tmp_path): + """Recovery works when the subprocess exits cleanly (exit code 0), not just crashes.""" + script = tmp_path / "exit_script.py" + script.write_text( + inspect.cleandoc(''' + import os, sys, threading + from fastmcp import FastMCP + + mcp = FastMCP() + call_count = 0 + + @mcp.tool + def pid_then_exit() -> int: + """Returns PID, exits cleanly after second call.""" + global call_count + call_count += 1 + pid = os.getpid() + if call_count >= 2: + threading.Timer(0.1, lambda: os._exit(0)).start() + return pid + + if __name__ == "__main__": + mcp.run() + ''') + ) + + client = Client( + transport=PythonStdioTransport(script_path=script), + init_timeout=self.INIT_TIMEOUT, + ) + + async with client: + result1 = await client.call_tool("pid_then_exit") + pid1: int = result1.data + # Second call triggers delayed clean exit + await client.call_tool("pid_then_exit") + await asyncio.sleep(0.3) + + # Recovery after clean exit + async with client: + result2 = await client.call_tool("pid_then_exit") + pid2: int = result2.data + + assert pid1 != pid2 + + async def test_crash_during_initialization(self, tmp_path): + """Recovery works when subprocess crashes during the first connection attempt.""" + # Script that exits immediately — crashes before init completes + crash_script = tmp_path / "crash_init.py" + crash_script.write_text( + inspect.cleandoc(""" + import sys + sys.exit(1) + """) + ) + + client = Client( + transport=PythonStdioTransport(script_path=crash_script), + init_timeout=self.INIT_TIMEOUT, + ) + + with pytest.raises(Exception): + async with client: + pass + + # Write a working script to the same path + crash_script.write_text( + inspect.cleandoc(""" + import os + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def pid() -> int: + return os.getpid() + + if __name__ == "__main__": + mcp.run() + """) + ) + + # Recovery with the now-working script + async with client: + result = await client.call_tool("pid") + assert isinstance(result.data, int) + + class TestLogFile: @pytest.fixture def stdio_script_with_stderr(self, tmp_path): diff --git a/tests/client/transports/test_memory_transport.py b/tests/client/transports/test_memory_transport.py new file mode 100644 index 000000000..5abdbdfb3 --- /dev/null +++ b/tests/client/transports/test_memory_transport.py @@ -0,0 +1,56 @@ +"""Tests for the in-memory FastMCPTransport. + +These tests verify transport-level behavior that affects all tests using +Client(server) with an in-process FastMCP server. +""" + +import time + +import pytest + +from fastmcp import Client, FastMCP + + +@pytest.mark.timeout(10) +async def test_task_teardown_does_not_hang(): + """In-memory transport must tear down in under 2 seconds after a task call. + + This is a regression test for a teardown ordering bug where the Docket + Worker shutdown would hang for 5 seconds on every test that used + task=True. The root cause was the server lifespan (which owns the Docket + Worker) being torn down BEFORE the task group (which owns the server's + run() and all its pub/sub subscriptions). Fakeredis blocking operations + held by those subscriptions prevented the Worker's internal TaskGroup + from cancelling its children, causing a 5-second stall until the + Client's move_on_after(5) timeout fired. + + The fix is to nest the task group INSIDE the lifespan context so that + all server tasks (and their fakeredis resources) are cancelled and + drained before Docket teardown begins. + + If this test takes ~5 seconds, the context manager nesting in + FastMCPTransport.connect_session() has been reversed — the lifespan + must be the OUTER context and the task group must be the INNER context. + """ + mcp = FastMCP("teardown-test") + + @mcp.tool(task=True) + async def fast_tool(x: int) -> int: + return x * 2 + + t0 = time.monotonic() + + async with Client(mcp) as client: + task = await client.call_tool("fast_tool", {"x": 21}, task=True) + result = await task.result() + assert result.data == 42 + + elapsed = time.monotonic() - t0 + + assert elapsed < 2.0, ( + f"Client teardown took {elapsed:.1f}s — expected <2s. " + f"This usually means the context manager nesting in " + f"FastMCPTransport.connect_session() is wrong: the lifespan " + f"must be the OUTER context and the task group the INNER context. " + f"See the comment in memory.py for details." + ) diff --git a/tests/client/transports/test_no_redirect.py b/tests/client/transports/test_no_redirect.py new file mode 100644 index 000000000..00af8911c --- /dev/null +++ b/tests/client/transports/test_no_redirect.py @@ -0,0 +1,198 @@ +"""Tests verifying that client transports do not leak auth credentials on redirects. + +httpx automatically strips Authorization headers on cross-origin redirects via its +_redirect_headers mechanism. These tests verify that FastMCP's transports rely on +this behavior correctly and do not override it. +""" + +import httpx +import pytest +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, RedirectResponse, Response +from starlette.routing import Route + +from fastmcp.client.transports.http import StreamableHttpTransport +from fastmcp.client.transports.sse import SSETransport + + +class TestHttpxBuiltinRedirectProtection: + """Verify httpx's built-in cross-origin redirect auth stripping.""" + + async def test_httpx_strips_auth_on_cross_origin_redirect(self): + """httpx strips Authorization headers when redirecting to a different origin.""" + received_headers: dict[str, str] = {} + + async def target_endpoint(request: Request) -> Response: + received_headers.update(dict(request.headers)) + return JSONResponse({"status": "ok"}) + + async def redirect_cross_origin(request: Request) -> Response: + return RedirectResponse( + url="http://other-host.example.com/target", + status_code=302, + ) + + app = Starlette( + routes=[ + Route("/redirect", redirect_cross_origin), + Route("/target", target_endpoint), + ] + ) + + # Use an httpx client with follow_redirects=True (as MCP does) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + follow_redirects=True, + ) as client: + response = await client.get( + "http://origin-host.example.com/redirect", + headers={"Authorization": "Bearer secret-token"}, + ) + + # httpx followed the redirect but stripped Authorization because + # the redirect target is a different origin + assert response.status_code == 200 + assert "authorization" not in received_headers + + async def test_httpx_preserves_auth_on_same_origin_redirect(self): + """httpx preserves Authorization headers when redirecting to the same origin.""" + received_headers: dict[str, str] = {} + + async def target_endpoint(request: Request) -> Response: + received_headers.update(dict(request.headers)) + return JSONResponse({"status": "ok"}) + + async def redirect_same_origin(request: Request) -> Response: + return RedirectResponse( + url="http://same-host.example.com/target", + status_code=302, + ) + + app = Starlette( + routes=[ + Route("/redirect", redirect_same_origin), + Route("/target", target_endpoint), + ] + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + follow_redirects=True, + ) as client: + response = await client.get( + "http://same-host.example.com/redirect", + headers={"Authorization": "Bearer secret-token"}, + ) + + assert response.status_code == 200 + assert received_headers.get("authorization") == "Bearer secret-token" + + @pytest.mark.parametrize( + "auth_header", + [ + "Bearer my-secret-token", + "Basic dXNlcjpwYXNz", + "Token ghp_xxxxxxxxxxxx", + ], + ) + async def test_various_auth_headers_stripped_on_cross_origin( + self, auth_header: str + ): + """Verify that different auth header formats are all stripped.""" + received_headers: dict[str, str] = {} + + async def target(request: Request) -> Response: + received_headers.update(dict(request.headers)) + return JSONResponse({"status": "ok"}) + + async def redirect(request: Request) -> Response: + return RedirectResponse( + url="http://evil.example.com/steal", + status_code=307, + ) + + app = Starlette( + routes=[ + Route("/api", redirect), + Route("/steal", target), + ] + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + follow_redirects=True, + ) as client: + response = await client.get( + "http://legit.example.com/api", + headers={"Authorization": auth_header}, + ) + + assert response.status_code == 200 + assert "authorization" not in received_headers + + +class TestMcpHttpClientRedirectProtection: + """Verify that MCP's default httpx client has redirect protection.""" + + async def test_create_mcp_http_client_strips_auth_on_cross_origin(self): + """create_mcp_http_client creates clients that strip auth on cross-origin redirects.""" + received_headers: dict[str, str] = {} + + async def target(request: Request) -> Response: + received_headers.update(dict(request.headers)) + return JSONResponse({"status": "ok"}) + + async def redirect(request: Request) -> Response: + return RedirectResponse( + url="http://evil.example.com/steal", + status_code=302, + ) + + app = Starlette( + routes=[ + Route("/api", redirect), + Route("/steal", target), + ] + ) + + # Use AsyncClient directly with ASGI transport rather than + # monkey-patching _transport on create_mcp_http_client, which + # breaks when proxy env vars are set. + client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + headers={"Authorization": "Bearer secret"}, + follow_redirects=True, + ) + + async with client: + response = await client.get("http://legit.example.com/api") + + assert response.status_code == 200 + assert "authorization" not in received_headers + + +class TestStreamableHttpTransportFactory: + """Verify factory and verify-factory redirect behavior.""" + + def test_verify_factory_still_enables_redirects(self): + """The verify factory should still create clients with follow_redirects=True.""" + transport = StreamableHttpTransport( + "https://example.com/mcp", + verify=False, + ) + factory = transport._make_verify_factory() + assert factory is not None + client = factory() + assert client.follow_redirects is True + + def test_sse_verify_factory_still_enables_redirects(self): + """The SSE verify factory should still create clients with follow_redirects=True.""" + transport = SSETransport( + "https://example.com/sse", + verify=False, + ) + factory = transport._make_verify_factory() + assert factory is not None + client = factory() + assert client.follow_redirects is True diff --git a/tests/client/transports/test_transports.py b/tests/client/transports/test_transports.py index acf6001cb..b2f319e09 100644 --- a/tests/client/transports/test_transports.py +++ b/tests/client/transports/test_transports.py @@ -1,7 +1,12 @@ +import ssl from ssl import VerifyMode +from typing import cast import httpx +import pytest +from mcp.shared._httpx_utils import McpHttpClientFactory +from fastmcp import Client from fastmcp.client.auth.oauth import OAuth from fastmcp.client.transports import SSETransport, StreamableHttpTransport @@ -19,7 +24,7 @@ async def test_oauth_uses_same_client_as_transport_streamable_http(): async with transport.auth.httpx_client_factory() as httpx_client: assert httpx_client._transport is not None assert ( - httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] + httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] == VerifyMode.CERT_NONE ) @@ -37,6 +42,222 @@ async def test_oauth_uses_same_client_as_transport_sse(): async with transport.auth.httpx_client_factory() as httpx_client: assert httpx_client._transport is not None assert ( - httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] + httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] == VerifyMode.CERT_NONE ) + + +class TestSSLVerify: + def test_streamable_http_transport_stores_verify_false(self): + transport = StreamableHttpTransport( + "https://example.com/mcp", + verify=False, + ) + assert transport.verify is False + + def test_streamable_http_transport_stores_verify_ssl_context(self): + ctx = ssl.create_default_context() + transport = StreamableHttpTransport( + "https://example.com/mcp", + verify=ctx, + ) + assert transport.verify is ctx + + def test_streamable_http_transport_stores_verify_cert_path(self): + transport = StreamableHttpTransport( + "https://example.com/mcp", + verify="/path/to/cert.pem", + ) + assert transport.verify == "/path/to/cert.pem" + + def test_streamable_http_transport_verify_default_is_none(self): + transport = StreamableHttpTransport("https://example.com/mcp") + assert transport.verify is None + + def test_sse_transport_stores_verify_false(self): + transport = SSETransport( + "https://example.com/sse", + verify=False, + ) + assert transport.verify is False + + def test_sse_transport_stores_verify_ssl_context(self): + ctx = ssl.create_default_context() + transport = SSETransport( + "https://example.com/sse", + verify=ctx, + ) + assert transport.verify is ctx + + def test_sse_transport_verify_default_is_none(self): + transport = SSETransport("https://example.com/sse") + assert transport.verify is None + + def test_client_passes_verify_to_streamable_http_transport(self): + client = Client("https://example.com/mcp", verify=False) + assert isinstance(client.transport, StreamableHttpTransport) + assert client.transport.verify is False + + def test_client_passes_verify_ssl_context_to_transport(self): + ctx = ssl.create_default_context() + client = Client("https://example.com/mcp", verify=ctx) + assert isinstance(client.transport, StreamableHttpTransport) + assert client.transport.verify is ctx + + def test_client_passes_verify_cert_path_to_transport(self): + client = Client( + "https://example.com/mcp", + verify="/path/to/cert.pem", + ) + assert isinstance(client.transport, StreamableHttpTransport) + assert client.transport.verify == "/path/to/cert.pem" + + def test_client_verify_none_leaves_transport_default(self): + client = Client("https://example.com/mcp") + assert isinstance(client.transport, StreamableHttpTransport) + assert client.transport.verify is None + + def test_client_verify_raises_for_non_http_transport(self): + from fastmcp import FastMCP + + server = FastMCP("test") + with pytest.raises( + ValueError, + match="only supported for HTTP transports", + ): + Client(server, verify=False) + + def test_client_passes_verify_to_sse_transport(self): + client = Client("https://example.com/sse", verify=False) + assert isinstance(client.transport, SSETransport) + assert client.transport.verify is False + + async def test_streamable_http_verify_propagates_to_oauth(self): + transport = StreamableHttpTransport( + "https://example.com/mcp", + verify=False, + auth="oauth", + ) + assert isinstance(transport.auth, OAuth) + async with transport.auth.httpx_client_factory() as httpx_client: + assert ( + httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + == VerifyMode.CERT_NONE + ) + + async def test_sse_verify_propagates_to_oauth(self): + transport = SSETransport( + "https://example.com/sse", + verify=False, + auth="oauth", + ) + assert isinstance(transport.auth, OAuth) + async with transport.auth.httpx_client_factory() as httpx_client: + assert ( + httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + == VerifyMode.CERT_NONE + ) + + async def test_client_verify_propagates_to_oauth(self): + client = Client( + "https://example.com/mcp", + verify=False, + auth="oauth", + ) + assert isinstance(client.transport, StreamableHttpTransport) + assert isinstance(client.transport.auth, OAuth) + async with client.transport.auth.httpx_client_factory() as httpx_client: + assert ( + httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + == VerifyMode.CERT_NONE + ) + + async def test_verify_propagates_to_preconstructed_oauth_instance(self): + transport = StreamableHttpTransport( + "https://example.com/mcp", + verify=False, + auth=OAuth(), + ) + assert isinstance(transport.auth, OAuth) + async with transport.auth.httpx_client_factory() as httpx_client: + assert ( + httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + == VerifyMode.CERT_NONE + ) + + async def test_client_verify_resyncs_existing_oauth_on_transport(self): + transport = StreamableHttpTransport( + "https://example.com/mcp", + auth="oauth", + ) + assert isinstance(transport.auth, OAuth) + # OAuth was created without verify — factory should be default + async with transport.auth.httpx_client_factory() as httpx_client: + assert ( + httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + != VerifyMode.CERT_NONE + ) + + # Now wrap in Client with verify=False — should resync OAuth + client = Client(transport, verify=False) + assert isinstance(client.transport.auth, OAuth) + async with client.transport.auth.httpx_client_factory() as httpx_client: + assert ( + httpx_client._transport._pool._ssl_context.verify_mode + == VerifyMode.CERT_NONE + ) + + async def test_client_verify_overrides_transport_verify_in_oauth(self): + transport = StreamableHttpTransport( + "https://example.com/mcp", + verify=False, + auth="oauth", + ) + assert isinstance(transport.auth, OAuth) + # OAuth should initially have verify=False + async with transport.auth.httpx_client_factory() as httpx_client: + assert ( + httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + == VerifyMode.CERT_NONE + ) + + # Client overrides verify to True — OAuth should update + client = Client(transport, verify=True) + assert isinstance(client.transport.auth, OAuth) + async with client.transport.auth.httpx_client_factory() as httpx_client: + assert ( + httpx_client._transport._pool._ssl_context.verify_mode + != VerifyMode.CERT_NONE + ) + + async def test_oauth_custom_factory_preserved_with_verify(self): + custom_factory = cast( + McpHttpClientFactory, + lambda **kwargs: httpx.AsyncClient(verify=False, **kwargs), + ) + auth = OAuth(httpx_client_factory=custom_factory) + transport = StreamableHttpTransport( + "https://example.com/mcp", + verify=True, + auth=auth, + ) + assert isinstance(transport.auth, OAuth) + assert transport.auth.httpx_client_factory is custom_factory + + def test_warns_when_both_factory_and_verify_provided_streamable(self): + factory = cast(McpHttpClientFactory, httpx.AsyncClient) + with pytest.warns(UserWarning, match="httpx_client_factory.*takes precedence"): + StreamableHttpTransport( + "https://example.com/mcp", + httpx_client_factory=factory, + verify=False, + ) + + def test_warns_when_both_factory_and_verify_provided_sse(self): + factory = cast(McpHttpClientFactory, httpx.AsyncClient) + with pytest.warns(UserWarning, match="httpx_client_factory.*takes precedence"): + SSETransport( + "https://example.com/sse", + httpx_client_factory=factory, + verify=False, + ) diff --git a/tests/conformance/__init__.py b/tests/conformance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/conformance/expected-failures.yml b/tests/conformance/expected-failures.yml new file mode 100644 index 000000000..46b2081de --- /dev/null +++ b/tests/conformance/expected-failures.yml @@ -0,0 +1,6 @@ +server: + - completion-complete + - server-sse-polling + - resources-subscribe + - resources-unsubscribe + - dns-rebinding-protection diff --git a/tests/conformance/server.py b/tests/conformance/server.py new file mode 100644 index 000000000..d3edcbc82 --- /dev/null +++ b/tests/conformance/server.py @@ -0,0 +1,377 @@ +"""FastMCP conformance test server. + +Registers the exact tools, resources, and prompts expected by the +MCP conformance test suite (https://github.com/modelcontextprotocol/conformance). +""" + +import asyncio +import base64 +import json +import sys +from enum import Enum as PyEnum + +import mcp.types +from mcp.types import EmbeddedResource, ImageContent, TextContent +from pydantic import AnyUrl, BaseModel, Field + +from fastmcp import FastMCP +from fastmcp.exceptions import ToolError +from fastmcp.prompts import Message +from fastmcp.server.context import Context +from fastmcp.tools.function_tool import FunctionTool +from fastmcp.utilities.types import Audio, Image + +# Minimal 1x1 red PNG for image tests (89 bytes) +_1X1_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4" + "nGP4z8BQDwAEgAF/pooBPQAAAABJRU5ErkJggg==" +) + +# Minimal valid WAV: 16-bit mono PCM, 44100 Hz, single silent sample +_SILENT_WAV = ( + b"RIFF" + + (38).to_bytes(4, "little") + + b"WAVEfmt " + + (16).to_bytes(4, "little") + + (1).to_bytes(2, "little") # PCM + + (1).to_bytes(2, "little") # mono + + (44100).to_bytes(4, "little") # sample rate + + (88200).to_bytes(4, "little") # byte rate + + (2).to_bytes(2, "little") # block align + + (16).to_bytes(2, "little") # bits per sample + + b"data" + + (2).to_bytes(4, "little") + + (0).to_bytes(2, "little") # one silent sample +) + +server = FastMCP("conformance-test-server", dereference_schemas=False) + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + + +@server.tool(name="test_simple_text") +async def test_simple_text() -> str: + """A simple text tool for conformance testing.""" + return "This is a simple text response for testing." + + +@server.tool(name="test_image_content") +async def test_image_content() -> Image: + """Returns a PNG image.""" + return Image(data=_1X1_PNG, format="png") + + +@server.tool(name="test_audio_content") +async def test_audio_content() -> Audio: + """Returns WAV audio.""" + return Audio(data=_SILENT_WAV, format="wav") + + +@server.tool(name="test_embedded_resource") +async def test_embedded_resource() -> list: + """Returns an embedded resource.""" + return [ + EmbeddedResource( + type="resource", + resource=mcp.types.TextResourceContents( + uri=AnyUrl("test://embedded-resource"), + mimeType="text/plain", + text="This is an embedded resource content.", + ), + ) + ] + + +@server.tool(name="test_multiple_content_types") +async def test_multiple_content_types() -> list: + """Returns mixed text, image, and resource content.""" + return [ + TextContent(type="text", text="This is a text part of the response."), + ImageContent( + type="image", + data=base64.b64encode(_1X1_PNG).decode(), + mimeType="image/png", + ), + EmbeddedResource( + type="resource", + resource=mcp.types.TextResourceContents( + uri=AnyUrl("test://mixed-content-resource"), + mimeType="application/json", + text='{"test":"data","value":123}', + ), + ), + ] + + +@server.tool(name="test_error_handling") +async def test_error_handling() -> str: + """Always returns an error.""" + raise ToolError("This tool intentionally returns an error for testing") + + +@server.tool(name="test_tool_with_logging") +async def test_tool_with_logging(ctx: Context) -> str: + """Sends log notifications during execution.""" + await ctx.info("Tool execution started") + await asyncio.sleep(0.05) + await ctx.info("Tool processing data") + await asyncio.sleep(0.05) + await ctx.info("Tool execution completed") + return "Logging test complete." + + +@server.tool(name="test_tool_with_progress") +async def test_tool_with_progress(ctx: Context) -> str: + """Reports progress notifications.""" + await ctx.report_progress(0, 100) + await asyncio.sleep(0.05) + await ctx.report_progress(50, 100) + await asyncio.sleep(0.05) + await ctx.report_progress(100, 100) + return "Progress test complete." + + +@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, + ) + return f"Sampling result: {result}" + + +class _UserInfo(BaseModel): + username: str + email: str + + +@server.tool(name="test_elicitation") +async def test_elicitation(message: str, ctx: Context) -> str: + """Requests user input via elicitation.""" + result = await ctx.elicit(message, _UserInfo) + return f"Elicitation result: {result}" + + +class _UserStatus(str, PyEnum): + active = "active" + inactive = "inactive" + pending = "pending" + + +class _DefaultsForm(BaseModel): + name: str = Field(default="John Doe", description="User name") + age: int = Field(default=30, description="User age") + score: float = Field(default=95.5, description="User score") + status: _UserStatus = Field(default=_UserStatus.active, description="User status") + verified: bool = Field(default=True, description="Verification status") + + +@server.tool(name="test_elicitation_sep1034_defaults") +async def test_elicitation_sep1034_defaults(ctx: Context) -> str: + """Tests elicitation with default values per SEP-1034.""" + result = await ctx.elicit( + "Please review and update the form fields with defaults", + _DefaultsForm, + ) + return f"Elicitation completed: {result}" + + +@server.tool(name="test_elicitation_sep1330_enums") +async def test_elicitation_sep1330_enums(ctx: Context) -> str: + """Tests elicitation with enum schema improvements per SEP-1330.""" + result = await ctx.session.elicit( + message="Please select options from the enum fields", + requestedSchema={ + "type": "object", + "properties": { + "untitledSingle": { + "type": "string", + "description": "Select one option", + "enum": ["option1", "option2", "option3"], + }, + "titledSingle": { + "type": "string", + "description": "Select one option with titles", + "oneOf": [ + {"const": "value1", "title": "First Option"}, + {"const": "value2", "title": "Second Option"}, + {"const": "value3", "title": "Third Option"}, + ], + }, + "legacyEnum": { + "type": "string", + "description": "Select one option (legacy)", + "enum": ["opt1", "opt2", "opt3"], + "enumNames": [ + "Option One", + "Option Two", + "Option Three", + ], + }, + "untitledMulti": { + "type": "array", + "description": "Select multiple options", + "minItems": 1, + "maxItems": 3, + "items": { + "type": "string", + "enum": ["option1", "option2", "option3"], + }, + }, + "titledMulti": { + "type": "array", + "description": "Select multiple options with titles", + "minItems": 1, + "maxItems": 3, + "items": { + "anyOf": [ + {"const": "value1", "title": "First Choice"}, + {"const": "value2", "title": "Second Choice"}, + {"const": "value3", "title": "Third Choice"}, + ] + }, + }, + }, + "required": [], + }, + related_request_id=ctx.request_id, + ) + return f"Elicitation completed: action={result.action}, content={json.dumps(result.content or {})}" + + +async def _json_schema_2020_12_fn( + name: str | None = None, + address: dict | None = None, +) -> str: + """Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613).""" + return f"JSON Schema 2020-12 tool called with: name={name}, address={address}" + + +server.add_tool( + FunctionTool( + fn=_json_schema_2020_12_fn, + name="json_schema_2020_12_tool", + description="Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)", + parameters={ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"}, + }, + } + }, + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/$defs/address"}, + }, + "additionalProperties": False, + }, + ) +) + + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- + + +@server.resource( + "test://static-text", + name="Static text resource", + mime_type="text/plain", +) +async def static_text_resource() -> str: + """Returns static text content.""" + return "This is the content of the static text resource." + + +@server.resource( + "test://static-binary", + name="Static binary resource", + mime_type="image/png", +) +async def static_binary_resource() -> bytes: + """Returns a binary PNG image.""" + return _1X1_PNG + + +@server.resource( + "test://template/{id}/data", + name="Template resource", + mime_type="application/json", +) +async def template_resource(id: str) -> str: + """Returns JSON data with the template parameter substituted.""" + return json.dumps({"id": id, "templateTest": True, "data": f"Data for ID: {id}"}) + + +@server.resource( + "test://watched-resource", + name="Watched resource", + mime_type="text/plain", +) +async def watched_resource() -> str: + """A resource that supports subscriptions.""" + return "Watched resource content." + + +# --------------------------------------------------------------------------- +# Prompts +# --------------------------------------------------------------------------- + + +@server.prompt(name="test_simple_prompt") +async def test_simple_prompt() -> str: + """A simple prompt for conformance testing.""" + return "This is a simple prompt for testing." + + +@server.prompt(name="test_prompt_with_arguments") +async def test_prompt_with_arguments(arg1: str, arg2: str) -> str: + """A prompt that accepts arguments.""" + return f"Prompt with arguments: arg1='{arg1}', arg2='{arg2}'" + + +@server.prompt(name="test_prompt_with_embedded_resource") +async def test_prompt_with_embedded_resource(resourceUri: str) -> list: + """A prompt that returns an embedded resource.""" + return [ + Message( + EmbeddedResource( + type="resource", + resource=mcp.types.TextResourceContents( + uri=AnyUrl(resourceUri), + mimeType="text/plain", + text=f"Content of resource {resourceUri}", + ), + ) + ), + ] + + +@server.prompt(name="test_prompt_with_image") +async def test_prompt_with_image() -> list: + """A prompt that returns an image.""" + return [ + Message( + ImageContent( + type="image", + data=base64.b64encode(_1X1_PNG).decode(), + mimeType="image/png", + ) + ), + Message("Please analyze the image above."), + ] + + +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) diff --git a/tests/conformance/test_conformance.py b/tests/conformance/test_conformance.py new file mode 100644 index 000000000..971efbfa4 --- /dev/null +++ b/tests/conformance/test_conformance.py @@ -0,0 +1,98 @@ +"""Run the MCP conformance test suite against a FastMCP server. + +Requires Node.js and npx to be available on PATH. +Mark: pytest -m conformance +""" + +import shutil +import socket +import subprocess +import threading +import time +from pathlib import Path + +import pytest +import uvicorn + +CONFORMANCE_DIR = Path(__file__).parent +EXPECTED_FAILURES = CONFORMANCE_DIR / "expected-failures.yml" +HOST = "127.0.0.1" +MCP_PATH = "/mcp" + + +def _get_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + s.listen(1) + return s.getsockname()[1] + + +@pytest.fixture(scope="module") +def _require_npx(): + if shutil.which("npx") is None: + pytest.skip("npx not found on PATH — install Node.js to run conformance tests") + + +@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 + + 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") + uv_server = uvicorn.Server(config) + + thread = threading.Thread(target=uv_server.run, daemon=True) + thread.start() + + # Wait for server to accept connections + url = f"http://{HOST}:{port}{MCP_PATH}" + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + with socket.create_connection((HOST, port), timeout=1): + break + except OSError: + time.sleep(0.1) + else: + pytest.fail("Conformance server did not start in time") + + yield url + + uv_server.should_exit = True + thread.join(timeout=5) + + +@pytest.mark.conformance +@pytest.mark.timeout(120) +def test_mcp_conformance(conformance_server): + """Run the full MCP conformance test suite against the server.""" + cmd = [ + "npx", + "--yes", + "@modelcontextprotocol/conformance@latest", + "server", + "--url", + conformance_server, + "--suite", + "all", + ] + + if EXPECTED_FAILURES.exists(): + cmd.extend(["--expected-failures", str(EXPECTED_FAILURES)]) + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=90) + + # Print output for visibility in test results + if result.stdout: + print(result.stdout) + if result.stderr: + print(result.stderr) + + assert result.returncode == 0, ( + f"Conformance tests failed (exit code {result.returncode}).\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) diff --git a/tests/conftest.py b/tests/conftest.py index 1401d4141..1edf55c60 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,10 @@ import asyncio import logging +import secrets import socket import sys from collections.abc import Callable, Generator +from datetime import timedelta from pathlib import Path from typing import Any @@ -60,11 +62,20 @@ def isolate_settings_home(tmp_path: Path): This prevents file locking issues when multiple tests share the same storage directory in settings.home / "oauth-proxy". + + Also sets a fast Docket polling interval for tests — the default 50ms + is fine for production but still adds ~25ms average pickup latency per + task. 10ms makes task tests near-instant. """ test_home = tmp_path / "fastmcp-test-home" test_home.mkdir(exist_ok=True) - with temporary_settings(home=test_home): + with temporary_settings( + home=test_home, + docket__minimum_check_interval=timedelta(milliseconds=10), + docket__url=f"memory://{secrets.token_hex(4)}", + client_disconnect_timeout=1, + ): yield diff --git a/tests/contrib/test_bulk_tool_caller.py b/tests/contrib/test_bulk_tool_caller.py index eb641550b..24873f4ea 100644 --- a/tests/contrib/test_bulk_tool_caller.py +++ b/tests/contrib/test_bulk_tool_caller.py @@ -10,7 +10,7 @@ from fastmcp.contrib.bulk_tool_caller.bulk_tool_caller import ( CallToolRequest, CallToolRequestResult, ) -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool class ToolException(Exception): @@ -286,3 +286,64 @@ async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller) ), ] ) + + +async def test_call_tools_bulk_blocks_self_invocation(bulk_caller_live: BulkToolCaller): + """Test call_tools_bulk blocks recursive calls to bulk tools.""" + tool_calls = [ + CallToolRequest(tool="call_tools_bulk", arguments={"tool_calls": []}), + CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "success_value"}), + ] + + results = await bulk_caller_live.call_tools_bulk(tool_calls, continue_on_error=True) + + assert results == snapshot( + [ + CallToolRequestResult( + content=[ + TextContent( + type="text", + text=( + "BulkToolCaller cannot call itself. " + "The tools 'call_tools_bulk' and 'call_tool_bulk' are disallowed." + ), + ) + ], + isError=True, + tool="call_tools_bulk", + arguments={"tool_calls": []}, + ), + CallToolRequestResult( + content=[TextContent(type="text", text="success_value")], + tool="echo_tool", + arguments={"arg1": "success_value"}, + ), + ] + ) + + +async def test_call_tool_bulk_blocks_self_invocation(bulk_caller_live: BulkToolCaller): + """Test call_tool_bulk blocks recursive calls to bulk tools.""" + + results = await bulk_caller_live.call_tool_bulk( + "call_tool_bulk", [{"arg1": "value1"}], continue_on_error=False + ) + + assert results == snapshot( + [ + CallToolRequestResult( + content=[ + TextContent( + type="text", + text=( + "BulkToolCaller cannot call itself. " + "The tools 'call_tools_bulk' and 'call_tool_bulk' are disallowed." + ), + ) + ], + isError=True, + tool="call_tool_bulk", + arguments={"arg1": "value1"}, + ) + ] + ) diff --git a/tests/contrib/test_mcp_mixin.py b/tests/contrib/test_mcp_mixin.py index 22bf02ceb..0d58a05a9 100644 --- a/tests/contrib/test_mcp_mixin.py +++ b/tests/contrib/test_mcp_mixin.py @@ -348,7 +348,7 @@ class TestMCPMixinKwargsSync: """Verify that the valid-kwarg sets stay in sync with from_function signatures.""" def test_tool_valid_kwargs_match_from_function(self): - from fastmcp.tools.tool import Tool + from fastmcp.tools.base import Tool expected = frozenset( p for p in inspect.signature(Tool.from_function).parameters if p != "fn" @@ -356,7 +356,7 @@ class TestMCPMixinKwargsSync: assert _TOOL_VALID_KWARGS == expected def test_resource_valid_kwargs_match_from_function(self): - from fastmcp.resources.resource import Resource + from fastmcp.resources.base import Resource expected = frozenset( p @@ -366,7 +366,7 @@ class TestMCPMixinKwargsSync: assert _RESOURCE_VALID_KWARGS == expected def test_prompt_valid_kwargs_match_from_function(self): - from fastmcp.prompts.prompt import Prompt + from fastmcp.prompts.base import Prompt expected = frozenset( p for p in inspect.signature(Prompt.from_function).parameters if p != "fn" diff --git a/tests/deprecated/test_exclude_args.py b/tests/deprecated/test_exclude_args.py index d6ed9d9d3..f01d5f6b7 100644 --- a/tests/deprecated/test_exclude_args.py +++ b/tests/deprecated/test_exclude_args.py @@ -4,7 +4,7 @@ import pytest from mcp.server.session import ServerSession from fastmcp import Client, FastMCP -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool async def test_tool_exclude_args(): diff --git a/tests/deprecated/test_function_component_imports.py b/tests/deprecated/test_function_component_imports.py index 77166b28d..73c07dec9 100644 --- a/tests/deprecated/test_function_component_imports.py +++ b/tests/deprecated/test_function_component_imports.py @@ -13,7 +13,7 @@ class TestDeprecatedFunctionToolImports: with pytest.warns( DeprecationWarning, match="Import from fastmcp.tools.function_tool" ): - from fastmcp.tools.tool import FunctionTool + from fastmcp.tools.base import FunctionTool # Verify it's the real class from fastmcp.tools.function_tool import ( @@ -27,7 +27,7 @@ class TestDeprecatedFunctionToolImports: with pytest.warns( DeprecationWarning, match="Import from fastmcp.tools.function_tool" ): - from fastmcp.tools.tool import ParsedFunction + from fastmcp.tools.base import ParsedFunction from fastmcp.tools.function_tool import ( ParsedFunction as CanonicalParsedFunction, @@ -40,7 +40,7 @@ class TestDeprecatedFunctionToolImports: with pytest.warns( DeprecationWarning, match="Import from fastmcp.tools.function_tool" ): - from fastmcp.tools.tool import tool + from fastmcp.tools.base import tool from fastmcp.tools.function_tool import tool as canonical_tool @@ -50,7 +50,7 @@ class TestDeprecatedFunctionToolImports: with temporary_settings(deprecation_warnings=False): with warnings.catch_warnings(): warnings.simplefilter("error") - from fastmcp.tools.tool import FunctionTool # noqa: F401 + from fastmcp.tools.base import FunctionTool # noqa: F401 class TestDeprecatedFunctionResourceImports: @@ -60,7 +60,7 @@ class TestDeprecatedFunctionResourceImports: DeprecationWarning, match="Import from fastmcp.resources.function_resource", ): - from fastmcp.resources.resource import FunctionResource + from fastmcp.resources.base import FunctionResource from fastmcp.resources.function_resource import ( FunctionResource as CanonicalFunctionResource, @@ -74,7 +74,7 @@ class TestDeprecatedFunctionResourceImports: DeprecationWarning, match="Import from fastmcp.resources.function_resource", ): - from fastmcp.resources.resource import resource + from fastmcp.resources.base import resource from fastmcp.resources.function_resource import ( resource as canonical_resource, @@ -86,7 +86,7 @@ class TestDeprecatedFunctionResourceImports: with temporary_settings(deprecation_warnings=False): with warnings.catch_warnings(): warnings.simplefilter("error") - from fastmcp.resources.resource import FunctionResource # noqa: F401 + from fastmcp.resources.base import FunctionResource # noqa: F401 class TestDeprecatedFunctionPromptImports: @@ -95,7 +95,7 @@ class TestDeprecatedFunctionPromptImports: with pytest.warns( DeprecationWarning, match="Import from fastmcp.prompts.function_prompt" ): - from fastmcp.prompts.prompt import FunctionPrompt + from fastmcp.prompts.base import FunctionPrompt from fastmcp.prompts.function_prompt import ( FunctionPrompt as CanonicalFunctionPrompt, @@ -108,7 +108,7 @@ class TestDeprecatedFunctionPromptImports: with pytest.warns( DeprecationWarning, match="Import from fastmcp.prompts.function_prompt" ): - from fastmcp.prompts.prompt import prompt + from fastmcp.prompts.base import prompt from fastmcp.prompts.function_prompt import prompt as canonical_prompt @@ -118,4 +118,4 @@ class TestDeprecatedFunctionPromptImports: with temporary_settings(deprecation_warnings=False): with warnings.catch_warnings(): warnings.simplefilter("error") - from fastmcp.prompts.prompt import FunctionPrompt # noqa: F401 + from fastmcp.prompts.base import FunctionPrompt # noqa: F401 diff --git a/tests/deprecated/test_import_server.py b/tests/deprecated/test_import_server.py index 403617bbe..3ee808a8b 100644 --- a/tests/deprecated/test_import_server.py +++ b/tests/deprecated/test_import_server.py @@ -5,8 +5,8 @@ from mcp.types import TextContent, TextResourceContents from fastmcp.client.client import Client from fastmcp.server.server import FastMCP +from fastmcp.tools.base import Tool from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool import Tool from tests.conftest import get_fn_name diff --git a/tests/deprecated/test_openapi_deprecations.py b/tests/deprecated/test_openapi_deprecations.py index b57611c6f..ee561a376 100644 --- a/tests/deprecated/test_openapi_deprecations.py +++ b/tests/deprecated/test_openapi_deprecations.py @@ -5,8 +5,6 @@ import warnings import pytest -pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning") - class TestExperimentalOpenAPIImportDeprecation: """Test experimental OpenAPI import path deprecations.""" diff --git a/tests/deprecated/test_tool_injection_middleware.py b/tests/deprecated/test_tool_injection_middleware.py new file mode 100644 index 000000000..af4f5df1c --- /dev/null +++ b/tests/deprecated/test_tool_injection_middleware.py @@ -0,0 +1,245 @@ +"""Tests for deprecated PromptToolMiddleware and ResourceToolMiddleware.""" + +import pytest +from inline_snapshot import snapshot +from mcp.types import TextContent +from mcp.types import Tool as SDKTool + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.client import CallToolResult +from fastmcp.client.transports import FastMCPTransport +from fastmcp.server.middleware.tool_injection import ( + PromptToolMiddleware, + ResourceToolMiddleware, +) + + +class TestPromptToolMiddleware: + """Tests for PromptToolMiddleware.""" + + @pytest.fixture + def server_with_prompts(self): + """Create a FastMCP server with prompts.""" + mcp = FastMCP("PromptServer") + + @mcp.tool + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + @mcp.prompt + def greeting(name: str) -> str: + """Generate a greeting message.""" + return f"Hello, {name}!" + + @mcp.prompt + def farewell(name: str) -> str: + """Generate a farewell message.""" + return f"Goodbye, {name}!" + + return mcp + + async def test_prompt_tools_added_to_list(self, server_with_prompts: FastMCP): + """Test that prompt tools are added to the tool list.""" + middleware = PromptToolMiddleware() + server_with_prompts.add_middleware(middleware) + + async with Client[FastMCPTransport](server_with_prompts) as client: + tools: list[SDKTool] = await client.list_tools() + + tool_names: list[str] = [tool.name for tool in tools] + # Should have: add, list_prompts, get_prompt + assert len(tools) == 3 + assert "add" in tool_names + assert "list_prompts" in tool_names + assert "get_prompt" in tool_names + + async def test_list_prompts_tool_works(self, server_with_prompts: FastMCP): + """Test that the list_prompts tool can be called.""" + middleware = PromptToolMiddleware() + server_with_prompts.add_middleware(middleware) + + async with Client[FastMCPTransport](server_with_prompts) as client: + result: CallToolResult = await client.call_tool( + name="list_prompts", arguments={} + ) + + assert result.content == snapshot( + [ + TextContent( + type="text", + text='[{"name":"greeting","title":null,"description":"Generate a greeting message.","arguments":[{"name":"name","description":null,"required":true}],"icons":null,"_meta":{"fastmcp":{"tags":[]}}},{"name":"farewell","title":null,"description":"Generate a farewell message.","arguments":[{"name":"name","description":null,"required":true}],"icons":null,"_meta":{"fastmcp":{"tags":[]}}}]', + ) + ] + ) + assert result.structured_content is not None + assert result.structured_content["result"] == snapshot( + [ + { + "name": "greeting", + "title": None, + "description": "Generate a greeting message.", + "arguments": [ + {"name": "name", "description": None, "required": True} + ], + "icons": None, + "_meta": {"fastmcp": {"tags": []}}, + }, + { + "name": "farewell", + "title": None, + "description": "Generate a farewell message.", + "arguments": [ + {"name": "name", "description": None, "required": True} + ], + "icons": None, + "_meta": {"fastmcp": {"tags": []}}, + }, + ] + ) + + async def test_get_prompt_tool_works(self, server_with_prompts: FastMCP): + """Test that the get_prompt tool can be called.""" + middleware = PromptToolMiddleware() + server_with_prompts.add_middleware(middleware) + + async with Client[FastMCPTransport](server_with_prompts) as client: + result: CallToolResult = await client.call_tool( + name="get_prompt", + arguments={"name": "greeting", "arguments": {"name": "World"}}, + ) + + # The tool returns the prompt result with structured_content + assert result.content == snapshot( + [ + TextContent( + type="text", + text='{"_meta":null,"description":"Generate a greeting message.","messages":[{"role":"user","content":{"type":"text","text":"Hello, World!","annotations":null,"_meta":null}}]}', + ) + ] + ) + assert result.structured_content is not None + assert result.structured_content == snapshot( + { + "_meta": None, + "description": "Generate a greeting message.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Hello, World!", + "annotations": None, + "_meta": None, + }, + } + ], + } + ) + + +class TestResourceToolMiddleware: + """Tests for ResourceToolMiddleware.""" + + @pytest.fixture + def server_with_resources(self): + """Create a FastMCP server with resources.""" + mcp = FastMCP("ResourceServer") + + @mcp.tool + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + @mcp.resource("file://config.txt") + def config_resource() -> str: + """Get configuration.""" + return "debug=true" + + @mcp.resource("file://data.json") + def data_resource() -> str: + """Get data.""" + return '{"count": 42}' + + return mcp + + async def test_resource_tools_added_to_list(self, server_with_resources: FastMCP): + """Test that resource tools are added to the tool list.""" + middleware = ResourceToolMiddleware() + server_with_resources.add_middleware(middleware) + + async with Client[FastMCPTransport](server_with_resources) as client: + tools: list[SDKTool] = await client.list_tools() + + tool_names: list[str] = [tool.name for tool in tools] + # Should have: add, list_resources, read_resource + assert len(tools) == 3 + assert "add" in tool_names + assert "list_resources" in tool_names + assert "read_resource" in tool_names + + async def test_list_resources_tool_works(self, server_with_resources: FastMCP): + """Test that the list_resources tool can be called.""" + middleware = ResourceToolMiddleware() + server_with_resources.add_middleware(middleware) + + async with Client[FastMCPTransport](server_with_resources) as client: + result: CallToolResult = await client.call_tool( + name="list_resources", arguments={} + ) + + assert result.structured_content is not None + assert result.structured_content["result"] == snapshot( + [ + { + "name": "config_resource", + "title": None, + "uri": "file://config.txt/", + "description": "Get configuration.", + "mimeType": "text/plain", + "size": None, + "icons": None, + "annotations": None, + "_meta": {"fastmcp": {"tags": []}}, + }, + { + "name": "data_resource", + "title": None, + "uri": "file://data.json/", + "description": "Get data.", + "mimeType": "text/plain", + "size": None, + "icons": None, + "annotations": None, + "_meta": {"fastmcp": {"tags": []}}, + }, + ] + ) + + async def test_read_resource_tool_works(self, server_with_resources: FastMCP): + """Test that the read_resource tool can be called.""" + middleware = ResourceToolMiddleware() + server_with_resources.add_middleware(middleware) + + async with Client[FastMCPTransport](server_with_resources) as client: + result: CallToolResult = await client.call_tool( + name="read_resource", arguments={"uri": "file://config.txt"} + ) + + assert result.content == snapshot( + [ + TextContent( + type="text", + text='{"contents":[{"content":"debug=true","mime_type":"text/plain","meta":null}],"meta":null}', + ) + ] + ) + assert result.structured_content == snapshot( + { + "contents": [ + {"content": "debug=true", "mime_type": "text/plain", "meta": None} + ], + "meta": None, + } + ) diff --git a/tests/deprecated/test_tool_serializer.py b/tests/deprecated/test_tool_serializer.py index 2b706ae74..640441b38 100644 --- a/tests/deprecated/test_tool_serializer.py +++ b/tests/deprecated/test_tool_serializer.py @@ -13,13 +13,10 @@ from mcp.types import TextContent from fastmcp import FastMCP from fastmcp.contrib.mcp_mixin import mcp_tool from fastmcp.server.providers import LocalProvider -from fastmcp.tools.tool import Tool, _convert_to_content +from fastmcp.tools.base import Tool, _convert_to_content from fastmcp.tools.tool_transform import TransformedTool from fastmcp.utilities.tests import temporary_settings -# Reset deprecation warnings for this module -pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning") - class TestToolSerializerDeprecated: """Tests for deprecated serializer functionality.""" diff --git a/tests/experimental/transforms/test_code_mode.py b/tests/experimental/transforms/test_code_mode.py index 454a8ccfb..6eb680eca 100644 --- a/tests/experimental/transforms/test_code_mode.py +++ b/tests/experimental/transforms/test_code_mode.py @@ -16,7 +16,7 @@ from fastmcp.experimental.transforms.code_mode import ( _ensure_async, ) from fastmcp.server.context import Context -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult def _unwrap_result(result: ToolResult) -> Any: @@ -362,7 +362,7 @@ async def test_code_mode_custom_discovery_tool_function() -> None: def list_all(get_catalog: GetToolCatalog) -> Tool: async def list_tools( - ctx: Context = None, # type: ignore[assignment] + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] ) -> str: """List all available tools.""" tools = await get_catalog(ctx) diff --git a/tests/experimental/transforms/test_code_mode_discovery.py b/tests/experimental/transforms/test_code_mode_discovery.py index cdc45ba1f..f49e32a08 100644 --- a/tests/experimental/transforms/test_code_mode_discovery.py +++ b/tests/experimental/transforms/test_code_mode_discovery.py @@ -11,7 +11,7 @@ from fastmcp.experimental.transforms.code_mode import ( Search, _ensure_async, ) -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.base import ToolResult def _unwrap_result(result: ToolResult) -> Any: diff --git a/tests/fs/test_discovery.py b/tests/fs/test_discovery.py index 261b93401..4f88bd34e 100644 --- a/tests/fs/test_discovery.py +++ b/tests/fs/test_discovery.py @@ -1,8 +1,11 @@ """Tests for filesystem discovery module.""" +import sys from pathlib import Path -from fastmcp.resources.template import FunctionResourceTemplate +from fastmcp.prompts.base import Prompt +from fastmcp.resources.base import Resource +from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate from fastmcp.server.providers.filesystem_discovery import ( discover_and_import, discover_files, @@ -10,6 +13,7 @@ from fastmcp.server.providers.filesystem_discovery import ( import_module_from_file, ) from fastmcp.tools import FunctionTool +from fastmcp.tools.base import Tool class TestDiscoverFiles: @@ -351,3 +355,232 @@ def bad_function(): failed_path = tmp_path / "bad.py" assert failed_path in result.failed_files assert "nonexistent_module_xyz123" in result.failed_files[failed_path] + + +class TestExtractComponentsVersion: + """Tests for version propagation in extract_components.""" + + def test_extract_tool_preserves_version(self, tmp_path: Path): + """Tools discovered from files should have their version attribute set.""" + tool_file = tmp_path / "versioned_tool.py" + tool_file.write_text( + """\ +from fastmcp.tools import tool + +@tool(version="1.0", description="v1") +def greet_v1(name: str) -> str: + return f"Hi {name}" + +@tool(version="2.0", description="v2") +def greet_v2(name: str) -> str: + return f"Hey {name}" +""" + ) + + module = import_module_from_file(tool_file) + components = extract_components(module) + + tools = [c for c in components if isinstance(c, Tool)] + assert len(tools) == 2 + + versions = {t.version for t in tools} + assert versions == {"1.0", "2.0"} + + def test_extract_resource_preserves_version(self, tmp_path: Path): + """Resources discovered from files should have their version attribute set.""" + resource_file = tmp_path / "versioned_resource.py" + resource_file.write_text( + """\ +from fastmcp.resources import resource + +@resource("data://config", version="1.0", name="config", description="v1 config") +def config_v1() -> str: + return '{"theme": "light"}' +""" + ) + + module = import_module_from_file(resource_file) + components = extract_components(module) + + resources = [c for c in components if isinstance(c, Resource)] + assert len(resources) == 1 + assert resources[0].version == "1.0" + + def test_extract_resource_template_preserves_version(self, tmp_path: Path): + """Resource templates discovered from files should have their version set.""" + template_file = tmp_path / "versioned_template.py" + template_file.write_text( + """\ +from fastmcp.resources import resource + +@resource("users://{user_id}/profile", version="2.0", description="v2 profile") +def get_profile(user_id: str) -> dict: + return {"id": user_id} +""" + ) + + module = import_module_from_file(template_file) + components = extract_components(module) + + templates = [c for c in components if isinstance(c, ResourceTemplate)] + assert len(templates) == 1 + assert templates[0].version == "2.0" + + def test_extract_prompt_preserves_version(self, tmp_path: Path): + """Prompts discovered from files should have their version attribute set.""" + prompt_file = tmp_path / "versioned_prompt.py" + prompt_file.write_text( + """\ +from fastmcp.prompts import prompt + +@prompt(name="summarize", version="1.0", description="v1 prompt") +def summarize_v1(text: str) -> str: + return f"Summarize: {text}" +""" + ) + + module = import_module_from_file(prompt_file) + components = extract_components(module) + + prompts = [c for c in components if isinstance(c, Prompt)] + assert len(prompts) == 1 + assert prompts[0].version == "1.0" + + def test_discovered_tool_meta_includes_version(self, tmp_path: Path): + """get_meta() should include version for tools discovered via filesystem.""" + tool_file = tmp_path / "meta_tool.py" + tool_file.write_text( + """\ +from fastmcp.tools import tool + +@tool(name="echo", version="3.0", description="Echo tool") +def echo(msg: str) -> str: + return msg +""" + ) + + module = import_module_from_file(tool_file) + components = extract_components(module) + + tool = components[0] + meta = tool.get_meta() + assert meta["fastmcp"]["version"] == "3.0" + + def test_unversioned_components_have_no_version(self, tmp_path: Path): + """Components without version should have version=None.""" + tool_file = tmp_path / "no_version_tool.py" + tool_file.write_text( + """\ +from fastmcp.tools import tool + +@tool(description="No version") +def my_tool(x: str) -> str: + return x +""" + ) + + module = import_module_from_file(tool_file) + components = extract_components(module) + + assert len(components) == 1 + assert components[0].version is None + meta = components[0].get_meta() + assert "version" not in meta["fastmcp"] + + +class TestImportMachineryFixes: + """Tests for import machinery correctness: sys.path cleanup, sys.modules safety, package root boundary.""" + + def test_syspath_not_polluted_after_import(self, tmp_path: Path): + """sys.path should not contain the file's parent after import_module_from_file returns.""" + (tmp_path / "mymod.py").write_text("VALUE = 1") + path_before = list(sys.path) + import_module_from_file(tmp_path / "mymod.py") + assert sys.path == path_before + + def test_syspath_not_polluted_after_package_import(self, tmp_path: Path): + """sys.path should not contain the package root's parent after a package import.""" + pkg = tmp_path / "mypkg_syspath" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "mod.py").write_text("VALUE = 2") + path_before = list(sys.path) + import_module_from_file(pkg / "mod.py", provider_root=tmp_path) + assert sys.path == path_before + + def test_stdlib_not_shadowed_by_same_named_file(self, tmp_path: Path): + """A provider file named json.py must not overwrite sys.modules['json'].""" + import json as stdlib_json + + saved = sys.modules["json"] + try: + (tmp_path / "json.py").write_text( + "from fastmcp.tools import tool\n@tool\ndef parse(): return 'provider'" + ) + import_module_from_file(tmp_path / "json.py") + assert sys.modules.get("json") is stdlib_json + finally: + sys.modules["json"] = saved + + def test_same_stem_files_get_independent_modules(self, tmp_path: Path): + """Two files with the same stem in different directories must not collide in sys.modules. + + The first-imported file keeps the bare stem key; the second gets a private key. + Both modules must be independently accessible with correct content. + """ + dir_a = tmp_path / "a" + dir_b = tmp_path / "b" + dir_a.mkdir() + dir_b.mkdir() + (dir_a / "helpers.py").write_text("ORIGIN = 'a'") + (dir_b / "helpers.py").write_text("ORIGIN = 'b'") + + mod_a = import_module_from_file(dir_a / "helpers.py") + mod_b = import_module_from_file(dir_b / "helpers.py") + + assert mod_a.ORIGIN == "a" + assert mod_b.ORIGIN == "b" + # The first module retains the bare stem key; the second uses a private key. + # They must be distinct objects — the second import must not have clobbered the first. + assert mod_a is not mod_b + assert sys.modules.get("helpers") is not mod_b + + def test_package_root_bounded_by_provider_root(self, tmp_path: Path): + """When the provider root is nested inside a larger package, import_module_from_file + with provider_root must not escape into ancestor packages. + + The generated module name should be relative to the provider root (e.g. "myprovider.tools"), + not to an ancestor package (e.g. "myproject.myprovider.tools"), and tmp_path (the + ancestor's parent) must not be added to sys.path. + """ + # Use a name that won't collide with any installed package + project = tmp_path / "myproject" + project.mkdir() + (project / "__init__.py").write_text("") + provider = project / "myprovider" + provider.mkdir() + (provider / "__init__.py").write_text("") + (provider / "tools.py").write_text("VALUE = 42") + + path_before = set(sys.path) + mod = import_module_from_file(provider / "tools.py", provider_root=provider) + path_after = set(sys.path) + + # Module was correctly imported + assert mod.VALUE == 42 + # sys.path should not contain tmp_path (the ancestor's grandparent); + # that would only happen if the package root escaped past the provider boundary + assert str(tmp_path) not in (path_after - path_before) + # The module name is bounded to the provider root, not "myproject.myprovider.tools" + assert mod.__name__ == "myprovider.tools" + + def test_non_package_reload_returns_updated_content(self, tmp_path: Path): + """Re-importing a non-package file should reflect file changes (exec_module path).""" + f = tmp_path / "reloadable_np.py" + f.write_text("VALUE = 'original'") + mod = import_module_from_file(f) + assert mod.VALUE == "original" + + f.write_text("VALUE = 'updated'") + mod2 = import_module_from_file(f) + assert mod2.VALUE == "updated" diff --git a/tests/fs/test_provider.py b/tests/fs/test_provider.py index 03518e795..37184a815 100644 --- a/tests/fs/test_provider.py +++ b/tests/fs/test_provider.py @@ -419,3 +419,105 @@ def charge(amount: float) -> str: assert len(tools_list) == 2 names = {t.name for t in tools_list} assert names == {"greet", "charge"} + + +class TestFileSystemProviderVersioning: + """Tests for version propagation through FileSystemProvider.""" + + async def test_versioned_tool_via_provider(self, tmp_path: Path): + """FileSystemProvider should preserve tool version in list_tools output.""" + (tmp_path / "versioned.py").write_text( + """\ +from fastmcp.tools import tool + +@tool(version="1.0", description="v1 greet") +def greet(name: str) -> str: + return f"Hello, {name}!" +""" + ) + + provider = FileSystemProvider(tmp_path) + mcp = FastMCP("TestServer", providers=[provider]) + + async with Client(mcp) as client: + tools = await client.list_tools() + assert len(tools) == 1 + assert tools[0].name == "greet" + meta = tools[0].meta + assert meta is not None + assert meta["fastmcp"]["version"] == "1.0" + + async def test_versioned_resource_via_provider(self, tmp_path: Path): + """FileSystemProvider should preserve resource version.""" + (tmp_path / "versioned_resource.py").write_text( + """\ +from fastmcp.resources import resource + +@resource("data://config", version="2.0", name="config", description="v2 config") +def config() -> str: + return '{"theme": "dark"}' +""" + ) + + provider = FileSystemProvider(tmp_path) + mcp = FastMCP("TestServer", providers=[provider]) + + async with Client(mcp) as client: + resources = await client.list_resources() + assert len(resources) == 1 + assert resources[0].name == "config" + meta = resources[0].meta + assert meta is not None + assert meta["fastmcp"]["version"] == "2.0" + + async def test_versioned_prompt_via_provider(self, tmp_path: Path): + """FileSystemProvider should preserve prompt version.""" + (tmp_path / "versioned_prompt.py").write_text( + """\ +from fastmcp.prompts import prompt + +@prompt(name="summarize", version="1.0", description="v1 prompt") +def summarize(text: str) -> str: + return f"Summarize: {text}" +""" + ) + + provider = FileSystemProvider(tmp_path) + mcp = FastMCP("TestServer", providers=[provider]) + + async with Client(mcp) as client: + prompts = await client.list_prompts() + assert len(prompts) == 1 + assert prompts[0].name == "summarize" + meta = prompts[0].meta + assert meta is not None + assert meta["fastmcp"]["version"] == "1.0" + + async def test_multiple_tool_versions_via_provider(self, tmp_path: Path): + """FileSystemProvider should handle multiple versions of the same tool.""" + (tmp_path / "multi_version.py").write_text( + """\ +from fastmcp.tools import tool + +@tool(name="add", version="1.0", description="v1 add") +def add_v1(x: int, y: int) -> int: + return x + y + +@tool(name="add", version="2.0", description="v2 add with z") +def add_v2(x: int, y: int, z: int = 0) -> int: + return x + y + z +""" + ) + + provider = FileSystemProvider(tmp_path) + mcp = FastMCP("TestServer", providers=[provider]) + + async with Client(mcp) as client: + tools = await client.list_tools() + add_tools = [t for t in tools if t.name == "add"] + # list_tools deduplicates to the highest version + assert len(add_tools) == 1 + meta = add_tools[0].meta + assert meta is not None + assert meta["fastmcp"]["version"] == "2.0" + assert meta["fastmcp"]["versions"] == ["2.0", "1.0"] diff --git a/tests/integration_tests/auth/test_github_provider_integration.py b/tests/integration_tests/auth/test_github_provider_integration.py index 3563dd093..47d9c944a 100644 --- a/tests/integration_tests/auth/test_github_provider_integration.py +++ b/tests/integration_tests/auth/test_github_provider_integration.py @@ -119,7 +119,7 @@ def create_github_server_with_mock_callback(base_url: str) -> FastMCP: separator = "&" if "?" in str(params.redirect_uri) else "?" return f"{params.redirect_uri}{separator}{urlencode(callback_params)}" - auth.authorize = mock_authorize # type: ignore[assignment] + auth.authorize = mock_authorize # type: ignore[assignment] # ty:ignore[invalid-assignment] # Mock the token verifier to accept our fake tokens original_verify_token = auth._token_validator.verify_token @@ -136,7 +136,7 @@ def create_github_server_with_mock_callback(base_url: str) -> FastMCP: # Fall back to original verification for other tokens return await original_verify_token(token) - auth._token_validator.verify_token = mock_verify_token # type: ignore[assignment] + auth._token_validator.verify_token = mock_verify_token # type: ignore[assignment] # ty:ignore[invalid-assignment] # Create FastMCP server with mocked GitHub authentication server = FastMCP("GitHub OAuth Integration Test Server (Mock)", auth=auth) diff --git a/tests/prompts/test_prompt.py b/tests/prompts/test_prompt.py index 05d7c0f17..ec95126e5 100644 --- a/tests/prompts/test_prompt.py +++ b/tests/prompts/test_prompt.py @@ -2,7 +2,7 @@ import pytest from mcp.types import EmbeddedResource, TextResourceContents from pydantic import FileUrl -from fastmcp.prompts.prompt import ( +from fastmcp.prompts.base import ( Message, Prompt, PromptResult, @@ -493,6 +493,36 @@ class TestMessage: assert isinstance(mcp_msg.content, TextContent) assert mcp_msg.content.text == "Hello" + def test_message_passthrough_image_content(self): + """Test Message passes through ImageContent without JSON serialization.""" + from mcp.types import ImageContent + + img = ImageContent(type="image", data="base64data", mimeType="image/png") + msg = Message(img, role="user") + assert isinstance(msg.content, ImageContent) + assert msg.content.data == "base64data" + assert msg.content.mimeType == "image/png" + + def test_message_passthrough_audio_content(self): + """Test Message passes through AudioContent without JSON serialization.""" + from mcp.types import AudioContent + + audio = AudioContent(type="audio", data="base64audio", mimeType="audio/wav") + msg = Message(audio, role="user") + assert isinstance(msg.content, AudioContent) + assert msg.content.data == "base64audio" + assert msg.content.mimeType == "audio/wav" + + def test_message_image_content_to_mcp_prompt_message(self): + """Test that ImageContent round-trips through to_mcp_prompt_message.""" + from mcp.types import ImageContent + + img = ImageContent(type="image", data="base64data", mimeType="image/png") + msg = Message(img, role="user") + mcp_msg = msg.to_mcp_prompt_message() + assert isinstance(mcp_msg.content, ImageContent) + assert mcp_msg.content.data == "base64data" + class TestPromptResult: def test_promptresult_from_string(self): @@ -520,12 +550,12 @@ class TestPromptResult: def test_promptresult_rejects_single_message(self): """Test PromptResult rejects single Message (must be in list).""" with pytest.raises(TypeError, match="must be str or list"): - PromptResult(Message("Hello")) # type: ignore[arg-type] + PromptResult(Message("Hello")) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] def test_promptresult_rejects_dict(self): """Test PromptResult rejects dict.""" with pytest.raises(TypeError, match="must be str or list"): - PromptResult({"key": "value"}) # type: ignore[arg-type] + PromptResult({"key": "value"}) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] def test_promptresult_with_meta(self): """Test PromptResult with meta field.""" diff --git a/tests/prompts/test_standalone_decorator.py b/tests/prompts/test_standalone_decorator.py index b835303a0..14d642bde 100644 --- a/tests/prompts/test_standalone_decorator.py +++ b/tests/prompts/test_standalone_decorator.py @@ -115,7 +115,7 @@ class TestPromptDecorator: """@prompt should raise if both positional and keyword name are given.""" with pytest.raises(TypeError, match="Cannot specify.*both.*argument.*keyword"): - @prompt("name1", name="name2") # type: ignore[call-overload] + @prompt("name1", name="name2") # type: ignore[call-overload] # ty:ignore[invalid-argument-type] def my_prompt() -> str: return "hello" diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index 71e95ea99..cf8fb0c14 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -7,7 +7,7 @@ from pydantic import FileUrl from fastmcp.exceptions import ResourceError from fastmcp.resources import FileResource -from fastmcp.resources.resource import ResourceResult +from fastmcp.resources.base import ResourceResult @pytest.fixture @@ -119,3 +119,63 @@ class TestFileResource: await resource.read() finally: temp_file.chmod(0o644) # Restore permissions + + async def test_read_utf8_with_encoding(self, tmp_path: Path): + """FileResource should read UTF-8 files correctly when encoding is specified.""" + content = ( + "Smart quotes: \u201cleft\u201d and apostrophe\u2019s em-dash\u2014here" + ) + file = tmp_path / "utf8_test.md" + file.write_text(content, encoding="utf-8") + + resource = FileResource( + uri=FileUrl("file:///test/utf8"), + path=file, + mime_type="text/markdown", + encoding="utf-8", + ) + result = await resource.read() + assert result.contents[0].content == content + + async def test_default_encoding_is_utf8(self, tmp_path: Path): + """FileResource defaults to UTF-8, reading non-ASCII without explicit encoding.""" + content = "Smart quotes: \u201cleft\u201d and em-dash\u2014here" + file = tmp_path / "default_utf8_test.txt" + file.write_text(content, encoding="utf-8") + + resource = FileResource( + uri=FileUrl("file:///test/default"), + path=file, + ) + assert resource.encoding == "utf-8" + result = await resource.read() + assert result.contents[0].content == content + + async def test_encoding_ignored_for_binary(self, tmp_path: Path): + """Encoding field should be ignored when is_binary=True.""" + data = b"\x00\x01\x02\xff" + file = tmp_path / "binary_test.bin" + file.write_bytes(data) + + resource = FileResource( + uri=FileUrl("file:///test/binary"), + path=file, + mime_type="application/octet-stream", + encoding="utf-8", + ) + result = await resource.read() + assert result.contents[0].content == data + + async def test_read_latin1_with_encoding(self, tmp_path: Path): + """FileResource should read non-UTF-8 files when correct encoding is specified.""" + content = "na\u00efve" + file = tmp_path / "latin1_test.txt" + file.write_text(content, encoding="latin-1") + + resource = FileResource( + uri=FileUrl("file:///test/latin1"), + path=file, + encoding="latin-1", + ) + result = await resource.read() + assert result.contents[0].content == content diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py index 8c44221c9..c3490abe3 100644 --- a/tests/resources/test_function_resources.py +++ b/tests/resources/test_function_resources.py @@ -1,8 +1,8 @@ import pytest from pydantic import AnyUrl, BaseModel +from fastmcp.resources.base import ResourceContent from fastmcp.resources.function_resource import FunctionResource -from fastmcp.resources.resource import ResourceContent class TestFunctionResource: diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 1f43ce1d5..3b88545c5 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from fastmcp import Context, FastMCP from fastmcp.resources import ResourceTemplate from fastmcp.resources.function_resource import FunctionResource -from fastmcp.resources.template import match_uri_template +from fastmcp.resources.template import build_regex, match_uri_template class TestResourceTemplate: @@ -747,3 +747,62 @@ class TestContextHandling: # read() returns the raw value result = await resource.read() assert result == "item: 42" + + +class TestMalformedURITemplates: + """Test that malformed URI templates from remote servers don't crash.""" + + @pytest.mark.parametrize( + "template", + [ + "test://{bad-name}/path", + "test://{hyphen-param}/{other-param}/path", + "test://{1leading}/path", + "test://{123}/path", + ], + ids=[ + "hyphen_in_name", + "multiple_hyphens", + "leading_digit", + "all_digits", + ], + ) + def test_build_regex_returns_none_for_invalid_group_names(self, template: str): + assert build_regex(template) is None + + def test_build_regex_returns_none_for_duplicate_group_names(self): + assert build_regex("test://{a}/{a}/path") is None + + @pytest.mark.parametrize( + "template", + [ + "test://{bad-name}/path", + "test://{a}/{a}/path", + "test://{1leading}/path", + ], + ids=[ + "hyphen_in_name", + "duplicate_groups", + "leading_digit", + ], + ) + def test_match_uri_template_returns_none_for_malformed_templates( + self, template: str + ): + assert match_uri_template("test://anything/path", template) is None + + def test_resource_template_matches_returns_none_for_malformed_template(self): + template = ResourceTemplate( + uri_template="test://{bad-name}/path", + name="test", + parameters={}, + ) + assert template.matches("test://anything/path") is None + + def test_build_regex_still_works_for_valid_templates(self): + regex = build_regex("test://{name}/{id}") + assert regex is not None + match = regex.match("test://foo/123") + assert match is not None + assert match.group("name") == "foo" + assert match.group("id") == "123" diff --git a/tests/resources/test_resource_template_query_params.py b/tests/resources/test_resource_template_query_params.py index d68025f66..9d7f86b57 100644 --- a/tests/resources/test_resource_template_query_params.py +++ b/tests/resources/test_resource_template_query_params.py @@ -263,6 +263,73 @@ class TestQueryParameterWithWildcards: assert result["lines"] == 50 # provided +class TestBooleanQueryParameterValidation: + """Test that invalid boolean query parameter values raise errors.""" + + async def _make_template(self): + def get_config(name: str, enabled: bool = False) -> dict: + return {"name": name, "enabled": enabled} + + return ResourceTemplate.from_function( + fn=get_config, + uri_template="config://{name}{?enabled}", + name="test", + ) + + async def test_invalid_boolean_value_raises_error(self): + """Test that nonsense boolean values like 'banana' raise ValueError.""" + template = await self._make_template() + + with pytest.raises(ValueError, match="Invalid boolean value for enabled"): + resource = await template.create_resource( + "config://feature?enabled=banana", + {"name": "feature", "enabled": "banana"}, + ) + await resource.read() + + @pytest.mark.parametrize( + "value", ["true", "True", "TRUE", "1", "yes", "Yes", "YES"] + ) + async def test_valid_true_values(self, value: str): + """Test that all accepted truthy string values coerce to True.""" + template = await self._make_template() + + resource = await template.create_resource( + f"config://feature?enabled={value}", + {"name": "feature", "enabled": value}, + ) + result = await resource.read() + assert isinstance(result, dict) + assert result["enabled"] is True + + @pytest.mark.parametrize( + "value", ["false", "False", "FALSE", "0", "no", "No", "NO"] + ) + async def test_valid_false_values(self, value: str): + """Test that all accepted falsy string values coerce to False.""" + template = await self._make_template() + + resource = await template.create_resource( + f"config://feature?enabled={value}", + {"name": "feature", "enabled": value}, + ) + result = await resource.read() + assert isinstance(result, dict) + assert result["enabled"] is False + + @pytest.mark.parametrize("value", ["banana", "nope", "2", "truee", ""]) + async def test_various_invalid_boolean_values(self, value: str): + """Test that various invalid boolean strings raise ValueError.""" + template = await self._make_template() + + with pytest.raises(ValueError, match="Invalid boolean value for enabled"): + resource = await template.create_resource( + f"config://feature?enabled={value}", + {"name": "feature", "enabled": value}, + ) + await resource.read() + + class TestResourceTemplateFieldDefaults: """Test resource templates with Field() defaults.""" diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index 7936c8890..0507e9188 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -211,13 +211,13 @@ class TestResourceResult: def test_init_from_dict_raises_type_error(self): """Dict input raises TypeError - must use ResourceContent for serialization.""" with pytest.raises(TypeError, match="must be str, bytes, or list"): - ResourceResult({"page": 1, "total": 100}) # type: ignore[arg-type] + ResourceResult({"page": 1, "total": 100}) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] def test_init_from_single_resource_content_raises_type_error(self): """Single ResourceContent raises TypeError - must be in a list.""" content = ResourceContent(content="test", mime_type="text/html") with pytest.raises(TypeError, match="must be str, bytes, or list"): - ResourceResult(content) # type: ignore[arg-type] + ResourceResult(content) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] def test_init_from_list_of_resource_content(self): """List of ResourceContent is used directly.""" @@ -233,7 +233,7 @@ class TestResourceResult: def test_init_from_mixed_list_raises_type_error(self): """Mixed list items raise TypeError - all items must be ResourceContent.""" with pytest.raises(TypeError, match=r"contents\[0\] must be ResourceContent"): - ResourceResult(["text", b"bytes", {"key": "value"}]) # type: ignore[arg-type] + ResourceResult(["text", b"bytes", {"key": "value"}]) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] def test_init_preserves_meta(self): """Meta is preserved on ResourceResult.""" diff --git a/tests/resources/test_standalone_decorator.py b/tests/resources/test_standalone_decorator.py index 9b4218ebf..f1b1dbd40 100644 --- a/tests/resources/test_standalone_decorator.py +++ b/tests/resources/test_standalone_decorator.py @@ -22,7 +22,7 @@ class TestResourceDecorator: """@resource should require a URI argument.""" with pytest.raises(TypeError, match="requires a URI|was used incorrectly"): - @resource # type: ignore[arg-type] + @resource # type: ignore[arg-type] # ty:ignore[invalid-argument-type] def get_config() -> str: return "{}" diff --git a/tests/server/auth/oauth_proxy/conftest.py b/tests/server/auth/oauth_proxy/conftest.py index 802ca2348..4e402ad05 100644 --- a/tests/server/auth/oauth_proxy/conftest.py +++ b/tests/server/auth/oauth_proxy/conftest.py @@ -265,7 +265,7 @@ class MockTokenVerifier(TokenVerifier): self.required_scopes = required_scopes or ["read", "write"] self.verify_called = False - async def verify_token(self, token: str) -> AccessToken | None: # type: ignore[override] + async def verify_token(self, token: str) -> AccessToken | None: # type: ignore[override] # ty:ignore[invalid-method-override] """Mock token verification.""" self.verify_called = True return AccessToken( diff --git a/tests/server/auth/oauth_proxy/test_authorization.py b/tests/server/auth/oauth_proxy/test_authorization.py index 7a8a9b9e7..791ffbabd 100644 --- a/tests/server/auth/oauth_proxy/test_authorization.py +++ b/tests/server/auth/oauth_proxy/test_authorization.py @@ -20,7 +20,7 @@ class TestOAuthProxyAuthorization: client_id="test-client", client_secret="test-secret", redirect_uris=[AnyUrl("http://localhost:54321/callback")], - jwt_signing_key="test-secret", # type: ignore[call-arg] # Optional field in MCP SDK + jwt_signing_key="test-secret", # type: ignore[call-arg] # Optional field in MCP SDK # ty:ignore[unknown-argument] ) # Register client first (required for consent flow) diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py index b605e50fd..087a99a38 100644 --- a/tests/server/auth/oauth_proxy/test_oauth_proxy.py +++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py @@ -1,6 +1,8 @@ """Tests for OAuth proxy initialization and configuration.""" import httpx +import pytest +from authlib.integrations.httpx_client import AsyncOAuth2Client from key_value.aio.stores.memory import MemoryStore from starlette.applications import Starlette @@ -29,6 +31,7 @@ class TestOAuthProxyInitialization: ) assert proxy._upstream_token_endpoint == "https://auth.example.com/token" assert proxy._upstream_client_id == "client-123" + assert proxy._upstream_client_secret is not None assert proxy._upstream_client_secret.get_secret_value() == "secret-456" assert str(proxy.base_url) == "https://api.example.com/" @@ -100,3 +103,79 @@ class TestOAuthProxyInitialization: assert response.status_code == 200 metadata = response.json() assert metadata.get("client_id_metadata_document_supported") is True + + +class TestOptionalClientSecret: + """Tests for OAuthProxy without upstream_client_secret.""" + + def test_no_secret_requires_jwt_signing_key(self, jwt_verifier): + """OAuthProxy requires jwt_signing_key when client_secret is omitted.""" + with pytest.raises(ValueError, match="jwt_signing_key is required"): + OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="client-123", + token_verifier=jwt_verifier, + base_url="https://api.example.com", + client_storage=MemoryStore(), + ) + + def test_no_secret_with_jwt_key_succeeds(self, jwt_verifier): + """OAuthProxy initializes successfully without client_secret when jwt_signing_key is given.""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="client-123", + token_verifier=jwt_verifier, + base_url="https://api.example.com", + jwt_signing_key=b"a" * 32, + client_storage=MemoryStore(), + ) + assert proxy._upstream_client_secret is None + assert proxy._upstream_client_id == "client-123" + + def test_factory_method_without_secret(self, jwt_verifier): + """_create_upstream_oauth_client works when no secret is configured.""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="client-123", + token_verifier=jwt_verifier, + base_url="https://api.example.com", + jwt_signing_key=b"a" * 32, + client_storage=MemoryStore(), + ) + client = proxy._create_upstream_oauth_client() + assert isinstance(client, AsyncOAuth2Client) + assert client.client_id == "client-123" + + def test_factory_method_with_secret(self, jwt_verifier): + """_create_upstream_oauth_client includes the secret when configured.""" + 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="https://api.example.com", + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + ) + client = proxy._create_upstream_oauth_client() + assert isinstance(client, AsyncOAuth2Client) + assert client.client_secret == "secret-456" + + def test_consent_cookies_work_without_secret(self, jwt_verifier): + """Cookie signing/verification works using JWT key when no secret is configured.""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="client-123", + token_verifier=jwt_verifier, + base_url="https://api.example.com", + jwt_signing_key=b"a" * 32, + client_storage=MemoryStore(), + ) + signed = proxy._sign_cookie("test-payload") + assert proxy._verify_cookie(signed) == "test-payload" + assert proxy._verify_cookie("tampered.payload") is None diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index b7e16431f..739abf43e 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -7,7 +7,7 @@ import pytest from key_value.aio.stores.memory import MemoryStore from mcp.server.auth.handlers.token import TokenErrorResponse from mcp.server.auth.handlers.token import TokenHandler as SDKTokenHandler -from mcp.server.auth.provider import AuthorizationCode +from mcp.server.auth.provider import AccessToken, AuthorizationCode from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl @@ -17,6 +17,9 @@ from fastmcp.server.auth.oauth_proxy.models import ( DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS, DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS, ClientCode, + JTIMapping, + UpstreamTokenSet, + _hash_token, ) from fastmcp.server.auth.providers.jwt import JWTVerifier @@ -420,7 +423,9 @@ class TestUpstreamTokenStorageTTL: # by checking that we can still look up the tokens for refresh purposes. # # Extract the JTI from the refresh token to look up the mapping - refresh_payload = proxy.jwt_issuer.verify_token(result.refresh_token) + refresh_payload = proxy.jwt_issuer.verify_token( + result.refresh_token, expected_token_use="refresh" + ) refresh_jti = refresh_payload["jti"] # The JTI mapping should exist @@ -491,7 +496,9 @@ class TestUpstreamTokenStorageTTL: assert result.refresh_token is not None # Verify upstream tokens are accessible - refresh_payload = proxy.jwt_issuer.verify_token(result.refresh_token) + refresh_payload = proxy.jwt_issuer.verify_token( + result.refresh_token, expected_token_use="refresh" + ) refresh_jti = refresh_payload["jti"] jti_mapping = await proxy._jti_mapping_store.get(key=refresh_jti) @@ -501,3 +508,350 @@ class TestUpstreamTokenStorageTTL: key=jti_mapping.upstream_token_id ) assert upstream_tokens is not None + + async def test_refresh_expires_in_zero_issues_refresh_token(self, proxy): + """refresh_expires_in=0 should fall back to 30-day default. + + Keycloak returns refresh_expires_in=0 for offline tokens (offline_access scope), + meaning "no fixed time-based expiry". The proxy should still issue a PROXY_RT. + """ + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + await proxy.register_client(client) + + client_code = ClientCode( + code="test-auth-code-keycloak-offline", + client_id="test-client", + redirect_uri="http://localhost:12345/callback", + code_challenge="test-challenge", + code_challenge_method="S256", + scopes=["read", "write"], + idp_tokens={ + "access_token": "upstream-access-token-kc", + "refresh_token": "upstream-refresh-token-kc", + "expires_in": 3600, + "refresh_expires_in": 0, # Keycloak offline token convention + "token_type": "Bearer", + }, + expires_at=time.time() + 300, + created_at=time.time(), + ) + await proxy._code_store.put(key=client_code.code, value=client_code) + + auth_code = AuthorizationCode( + code="test-auth-code-keycloak-offline", + scopes=["read", "write"], + expires_at=time.time() + 300, + client_id="test-client", + code_challenge="test-challenge", + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + ) + + result = await proxy.exchange_authorization_code( + client=client, + authorization_code=auth_code, + ) + + # refresh_expires_in=0 must NOT prevent refresh token issuance + assert result.access_token is not None + assert result.refresh_token is not None + + # Verify refresh token metadata was stored + refresh_meta = await proxy._refresh_token_store.get( + key=_hash_token(result.refresh_token) + ) + assert refresh_meta is not None + + +class TestTransparentUpstreamRefresh: + """Tests for transparent upstream token refresh in load_access_token. + + When the upstream token expires but a refresh token is available, the proxy + should transparently refresh the upstream token rather than returning a 401 + that forces the client into a full re-authentication flow. + """ + + @pytest.fixture + def mock_verifier(self): + """Token verifier that rejects expired tokens, accepts refreshed ones.""" + verifier = Mock(spec=TokenVerifier) + verifier.required_scopes = ["read"] + + async def verify(token: str) -> AccessToken | None: + if token.startswith("refreshed-"): + return AccessToken( + token=token, + client_id="test-client", + scopes=["read"], + expires_at=int(time.time() + 3600), + ) + return None + + verifier.verify_token = AsyncMock(side_effect=verify) + return verifier + + @pytest.fixture + def proxy(self, mock_verifier): + proxy = OAuthProxy( + upstream_authorization_endpoint="https://idp.example.com/authorize", + upstream_token_endpoint="https://idp.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=mock_verifier, + base_url="https://proxy.example.com", + jwt_signing_key="test-secret-key", + client_storage=MemoryStore(), + ) + proxy.set_mcp_path("/mcp") + return proxy + + async def _setup_expired_session( + self, + proxy: OAuthProxy, + *, + upstream_refresh_token: str | None = "upstream-refresh-tok", + ) -> str: + """Set up a proxy JWT pointing at an expired upstream token. + + Returns the proxy JWT (access token) that can be passed to + load_access_token. + """ + upstream_token_id = "upstream-tok-id" + access_jti = "test-access-jti" + + upstream_token_set = UpstreamTokenSet( + upstream_token_id=upstream_token_id, + access_token="expired-upstream-access", + refresh_token=upstream_refresh_token, + refresh_token_expires_at=time.time() + 86400 + if upstream_refresh_token + else None, + expires_at=time.time() - 60, # expired 1 minute ago + token_type="Bearer", + scope="read", + client_id="test-client", + created_at=time.time() - 3600, + ) + await proxy._upstream_token_store.put( + key=upstream_token_id, + value=upstream_token_set, + ttl=86400, + ) + + await proxy._jti_mapping_store.put( + key=access_jti, + value=JTIMapping( + jti=access_jti, + upstream_token_id=upstream_token_id, + created_at=time.time(), + ), + ttl=3600, + ) + + fastmcp_jwt = proxy.jwt_issuer.issue_access_token( + client_id="test-client", + scopes=["read"], + jti=access_jti, + expires_in=3600, + ) + return fastmcp_jwt + + async def test_transparent_refresh_on_expired_upstream(self, proxy): + """load_access_token refreshes upstream token when validation fails.""" + fastmcp_jwt = await self._setup_expired_session(proxy) + + mock_oauth_client = AsyncMock() + mock_oauth_client.refresh_token = AsyncMock( + return_value={ + "access_token": "refreshed-upstream-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "upstream-refresh-tok", + "scope": "read", + } + ) + + with patch.object( + proxy, "_create_upstream_oauth_client", return_value=mock_oauth_client + ): + result = await proxy.load_access_token(fastmcp_jwt) + + assert result is not None + assert result.token == "refreshed-upstream-access" + mock_oauth_client.refresh_token.assert_called_once() + + async def test_transparent_refresh_updates_stored_token(self, proxy): + """After transparent refresh, the stored upstream token is updated.""" + fastmcp_jwt = await self._setup_expired_session(proxy) + + mock_oauth_client = AsyncMock() + mock_oauth_client.refresh_token = AsyncMock( + return_value={ + "access_token": "refreshed-upstream-access", + "token_type": "Bearer", + "expires_in": 7200, + "refresh_token": "upstream-refresh-tok", + "scope": "read", + } + ) + + with patch.object( + proxy, "_create_upstream_oauth_client", return_value=mock_oauth_client + ): + await proxy.load_access_token(fastmcp_jwt) + + stored = await proxy._upstream_token_store.get(key="upstream-tok-id") + assert stored is not None + assert stored.access_token == "refreshed-upstream-access" + assert stored.expires_at > time.time() + + async def test_no_refresh_without_refresh_token(self, proxy): + """Without a refresh token, load_access_token returns None immediately.""" + fastmcp_jwt = await self._setup_expired_session( + proxy, upstream_refresh_token=None + ) + + result = await proxy.load_access_token(fastmcp_jwt) + + assert result is None + + async def test_returns_none_when_refresh_fails(self, proxy): + """If the upstream refresh call fails, load_access_token returns None.""" + fastmcp_jwt = await self._setup_expired_session(proxy) + + mock_oauth_client = AsyncMock() + mock_oauth_client.refresh_token = AsyncMock( + side_effect=Exception("upstream refused refresh") + ) + + with patch.object( + proxy, "_create_upstream_oauth_client", return_value=mock_oauth_client + ): + result = await proxy.load_access_token(fastmcp_jwt) + + assert result is None + + async def test_returns_none_when_refreshed_token_still_invalid(self, proxy): + """If the refreshed token also fails validation, returns None.""" + fastmcp_jwt = await self._setup_expired_session(proxy) + + mock_oauth_client = AsyncMock() + mock_oauth_client.refresh_token = AsyncMock( + return_value={ + "access_token": "still-bad-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read", + } + ) + + with patch.object( + proxy, "_create_upstream_oauth_client", return_value=mock_oauth_client + ): + result = await proxy.load_access_token(fastmcp_jwt) + + # "still-bad-token" doesn't start with "refreshed-" so verifier rejects it + assert result is None + + async def test_no_refresh_when_token_not_expired(self, proxy): + """Non-expiry validation failures (e.g. revocation) should not trigger refresh.""" + upstream_token_id = "upstream-tok-id" + access_jti = "test-access-jti" + + # Token is NOT expired — verification failure is for another reason + upstream_token_set = UpstreamTokenSet( + upstream_token_id=upstream_token_id, + access_token="revoked-upstream-access", + refresh_token="upstream-refresh-tok", + refresh_token_expires_at=time.time() + 86400, + expires_at=time.time() + 3600, # still valid for 1 hour + token_type="Bearer", + scope="read", + client_id="test-client", + created_at=time.time() - 60, + ) + await proxy._upstream_token_store.put( + key=upstream_token_id, + value=upstream_token_set, + ttl=86400, + ) + await proxy._jti_mapping_store.put( + key=access_jti, + value=JTIMapping( + jti=access_jti, + upstream_token_id=upstream_token_id, + created_at=time.time(), + ), + ttl=3600, + ) + fastmcp_jwt = proxy.jwt_issuer.issue_access_token( + client_id="test-client", + scopes=["read"], + jti=access_jti, + expires_in=3600, + ) + + mock_oauth_client = AsyncMock() + mock_oauth_client.refresh_token = AsyncMock() + + with patch.object( + proxy, "_create_upstream_oauth_client", return_value=mock_oauth_client + ): + result = await proxy.load_access_token(fastmcp_jwt) + + assert result is None + # Refresh should NOT have been attempted + mock_oauth_client.refresh_token.assert_not_called() + + async def test_reload_from_storage_after_refresh_failure(self, proxy): + """If refresh fails, re-read from storage in case another worker refreshed.""" + fastmcp_jwt = await self._setup_expired_session(proxy) + + # Simulate: refresh fails (stale refresh token), but another worker + # already wrote a fresh upstream token to storage. + refreshed_token_set = UpstreamTokenSet( + upstream_token_id="upstream-tok-id", + access_token="refreshed-upstream-access", + refresh_token="new-refresh-tok", + refresh_token_expires_at=time.time() + 86400, + expires_at=time.time() + 3600, + token_type="Bearer", + scope="read", + client_id="test-client", + created_at=time.time(), + ) + + # The store is read three times: + # 1. Initial lookup in load_access_token + # 2. Re-read inside the advisory lock + # 3. Re-read after refresh failure (recovery path) + original_get = proxy._upstream_token_store.get + call_count = 0 + + async def mock_get(key: str) -> UpstreamTokenSet | None: + nonlocal call_count + call_count += 1 + if call_count <= 2: + return await original_get(key) + # After refresh failure, return the "other worker's" refreshed token + return refreshed_token_set + + mock_oauth_client = AsyncMock() + mock_oauth_client.refresh_token = AsyncMock( + side_effect=Exception("refresh token rotated by another worker") + ) + + with ( + patch.object( + proxy, "_create_upstream_oauth_client", return_value=mock_oauth_client + ), + patch.object(proxy._upstream_token_store, "get", side_effect=mock_get), + ): + result = await proxy.load_access_token(fastmcp_jwt) + + assert result is not None + assert result.token == "refreshed-upstream-access" diff --git a/tests/server/auth/oauth_proxy/test_ui.py b/tests/server/auth/oauth_proxy/test_ui.py index 4795ec2ff..6f174aec2 100644 --- a/tests/server/auth/oauth_proxy/test_ui.py +++ b/tests/server/auth/oauth_proxy/test_ui.py @@ -7,7 +7,7 @@ from starlette.requests import Request from starlette.responses import HTMLResponse from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.server.auth.oauth_proxy.ui import create_error_html +from fastmcp.server.auth.oauth_proxy.ui import create_consent_html, create_error_html from fastmcp.server.auth.providers.jwt import JWTVerifier @@ -99,3 +99,21 @@ class TestErrorPageRendering: assert b"invalid_scope" in response.body assert b"doesn't exist" in response.body # HTML-escaped apostrophe assert b"OAuth Error" in response.body + + +class TestConsentPageRendering: + """Test consent page rendering and escaping.""" + + def test_create_consent_html_escapes_client_id_in_details(self): + """Test that Application ID is escaped in advanced details.""" + + html = create_consent_html( + client_id='evil', + redirect_uri="https://example.com/callback", + scopes=["read"], + txn_id="txn", + csrf_token="csrf", + ) + + assert 'evil' not in html + assert "evil<img src=x onerror=alert("xss")>" in html diff --git a/tests/server/auth/providers/test_auth0.py b/tests/server/auth/providers/test_auth0.py index 3b1b5cd8f..2c8cb1b46 100644 --- a/tests/server/auth/providers/test_auth0.py +++ b/tests/server/auth/providers/test_auth0.py @@ -61,6 +61,7 @@ class TestAuth0Provider: assert str(call_args[0][0]) == TEST_CONFIG_URL assert provider._upstream_client_id == TEST_CLIENT_ID + assert provider._upstream_client_secret is not None assert ( provider._upstream_client_secret.get_secret_value() == TEST_CLIENT_SECRET diff --git a/tests/server/auth/providers/test_aws.py b/tests/server/auth/providers/test_aws.py index 9831dfaf8..8bbde78f7 100644 --- a/tests/server/auth/providers/test_aws.py +++ b/tests/server/auth/providers/test_aws.py @@ -53,6 +53,7 @@ class TestAWSCognitoProvider: # Check that the provider was initialized correctly assert provider._upstream_client_id == "test_client" + assert provider._upstream_client_secret is not None assert provider._upstream_client_secret.get_secret_value() == "test_secret" assert ( str(provider.base_url) == "https://example.com/" @@ -102,6 +103,36 @@ class TestAWSCognitoProvider: assert provider._upstream_token_endpoint is not None assert "amazoncognito.com" in provider._upstream_authorization_endpoint + def test_token_verifier_defaults_audience_to_client_id(self): + """Test Cognito token verifier enforces the configured client ID by default.""" + with mock_cognito_oidc_discovery(): + provider = AWSCognitoProvider( + user_pool_id="us-east-1_XXXXXXXXX", + client_id="test_client", + client_secret="test_secret", + base_url="https://example.com", + jwt_signing_key="test-secret", + ) + + verifier = provider.get_token_verifier() + + assert verifier.audience == "test_client" + + def test_token_verifier_supports_audience_override(self): + """Test Cognito token verifier still allows explicit audience overrides.""" + with mock_cognito_oidc_discovery(): + provider = AWSCognitoProvider( + user_pool_id="us-east-1_XXXXXXXXX", + client_id="test_client", + client_secret="test_secret", + base_url="https://example.com", + jwt_signing_key="test-secret", + ) + + verifier = provider.get_token_verifier(audience="custom-audience") + + assert verifier.audience == "custom-audience" + # Token verification functionality is now tested as part of the OIDC provider integration # The CognitoTokenVerifier class is an internal implementation detail diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 3d01c926d..d83bc8860 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -34,6 +34,7 @@ class TestAzureProvider: ) assert provider._upstream_client_id == "12345678-1234-1234-1234-123456789012" + assert provider._upstream_client_secret is not None assert provider._upstream_client_secret.get_secret_value() == "azure_secret_123" assert str(provider.base_url) == "https://myserver.com/" # Check tenant is in the endpoints diff --git a/tests/server/auth/providers/test_azure_scopes.py b/tests/server/auth/providers/test_azure_scopes.py index 9f90def35..edbfa3165 100644 --- a/tests/server/auth/providers/test_azure_scopes.py +++ b/tests/server/auth/providers/test_azure_scopes.py @@ -3,12 +3,14 @@ import pytest from key_value.aio.stores.memory import MemoryStore +from fastmcp.server.auth.auth import MultiAuth from fastmcp.server.auth.providers.azure import ( OIDC_SCOPES, AzureJWTVerifier, AzureProvider, + _find_azure_provider, ) -from fastmcp.server.auth.providers.jwt import RSAKeyPair +from fastmcp.server.auth.providers.jwt import RSAKeyPair, StaticTokenVerifier @pytest.fixture @@ -147,23 +149,60 @@ class TestOIDCScopeHandling: # Token validator should only require non-OIDC scopes assert provider._token_validator.required_scopes == ["read"] - def test_required_scopes_all_oidc_results_in_no_validation( + def test_required_scopes_all_oidc_raises_value_error( self, memory_storage: MemoryStore ): - """Test that if all required_scopes are OIDC, no scope validation occurs.""" - provider = AzureProvider( - client_id="test_client", - client_secret="test_secret", - tenant_id="test-tenant", - base_url="https://myserver.com", - identifier_uri="api://my-api", - required_scopes=["openid", "profile"], - jwt_signing_key="test-secret", - client_storage=memory_storage, - ) + """Test that providing only OIDC scopes raises ValueError.""" + with pytest.raises(ValueError, match="at least one non-OIDC scope"): + AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + base_url="https://myserver.com", + identifier_uri="api://my-api", + required_scopes=["openid", "profile"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) - # Token validator should have empty required scopes (all were OIDC) - assert provider._token_validator.required_scopes == [] + def test_empty_required_scopes_raises_value_error( + self, memory_storage: MemoryStore + ): + """Test that providing empty required_scopes raises ValueError.""" + with pytest.raises(ValueError, match="at least one non-OIDC scope"): + AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + base_url="https://myserver.com", + identifier_uri="api://my-api", + required_scopes=[], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + @pytest.mark.parametrize( + "scopes", + [ + ["offline_access"], + ["openid", "email", "profile", "offline_access"], + ["email"], + ], + ) + def test_only_oidc_scopes_raises_value_error( + self, memory_storage: MemoryStore, scopes: list[str] + ): + """Test that various OIDC-only scope combinations raise ValueError.""" + with pytest.raises(ValueError, match="at least one non-OIDC scope"): + AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + base_url="https://myserver.com", + required_scopes=scopes, + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) def test_valid_scopes_includes_oidc_scopes(self, memory_storage: MemoryStore): """Test that valid_scopes advertises OIDC scopes to clients.""" @@ -688,3 +727,42 @@ class TestAzureOBOIntegration: dep = _EntraOBOToken(["scope"]) assert isinstance(dep, Dependency) + + +class TestFindAzureProvider: + """Tests for _find_azure_provider helper used by EntraOBOToken.""" + + def test_returns_azure_provider_directly(self, memory_storage): + """When auth is an AzureProvider, return it directly.""" + provider = AzureProvider( + tenant_id="test-tenant", + client_id="test-client", + client_secret="test-secret", + client_storage=memory_storage, + base_url="https://example.com", + required_scopes=["read"], + ) + assert _find_azure_provider(provider) is provider + + def test_unwraps_multiauth_with_azure_server(self, memory_storage): + """When auth is a MultiAuth wrapping an AzureProvider, return the inner provider.""" + provider = AzureProvider( + tenant_id="test-tenant", + client_id="test-client", + client_secret="test-secret", + client_storage=memory_storage, + base_url="https://example.com", + required_scopes=["read"], + ) + multi = MultiAuth(server=provider) + assert _find_azure_provider(multi) is provider + + def test_returns_none_for_no_auth(self): + """When auth is None, return None.""" + assert _find_azure_provider(None) is None + + def test_returns_none_for_multiauth_without_azure_server(self): + """When MultiAuth has no server or a non-Azure server, return None.""" + verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) + multi = MultiAuth(verifiers=[verifier]) + assert _find_azure_provider(multi) is None diff --git a/tests/server/auth/providers/test_clerk.py b/tests/server/auth/providers/test_clerk.py new file mode 100644 index 000000000..323b36572 --- /dev/null +++ b/tests/server/auth/providers/test_clerk.py @@ -0,0 +1,572 @@ +"""Tests for Clerk OAuth provider.""" + +import re + +import httpx +import pytest +from key_value.aio.stores.memory import MemoryStore +from pytest_httpx import HTTPXMock + +from fastmcp.server.auth.providers.clerk import ClerkProvider, ClerkTokenVerifier + +CLERK_DOMAIN = "test-instance.clerk.accounts.dev" + +_USERINFO_RE = re.compile(rf"https://{re.escape(CLERK_DOMAIN)}/oauth/userinfo") +_INTROSPECTION_RE = re.compile(rf"https://{re.escape(CLERK_DOMAIN)}/oauth/token_info") + + +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + +class TestClerkProvider: + """Test Clerk OAuth provider functionality.""" + + def test_init_with_explicit_params(self, memory_storage: MemoryStore): + """Test ClerkProvider initialization with explicit parameters.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + required_scopes=["openid", "email", "profile"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert provider._upstream_client_id == "clerk-client-id" + assert provider._upstream_client_secret is not None + assert ( + provider._upstream_client_secret.get_secret_value() == "clerk-client-secret" + ) + assert str(provider.base_url) == "https://myserver.com/" + + def test_init_defaults(self, memory_storage: MemoryStore): + """Test that default values are applied correctly.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert provider._redirect_path == "/auth/callback" + + def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore): + """Test that OAuth endpoints are derived from the domain.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert ( + provider._upstream_authorization_endpoint + == f"https://{CLERK_DOMAIN}/oauth/authorize" + ) + assert ( + provider._upstream_token_endpoint == f"https://{CLERK_DOMAIN}/oauth/token" + ) + assert provider._upstream_revocation_endpoint is None + + def test_domain_trailing_slash_stripped(self, memory_storage: MemoryStore): + """Test that trailing slashes are stripped from the domain.""" + provider = ClerkProvider( + domain=f"{CLERK_DOMAIN}/", + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert ( + provider._upstream_authorization_endpoint + == f"https://{CLERK_DOMAIN}/oauth/authorize" + ) + + def test_default_scopes(self, memory_storage: MemoryStore): + """Test that default required scopes are openid, email, profile.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert provider is not None + + def test_custom_scopes(self, memory_storage: MemoryStore): + """Test that custom scopes are accepted.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + required_scopes=["openid", "email", "profile", "public_metadata"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert provider is not None + + def test_no_extra_authorize_params_by_default(self, memory_storage: MemoryStore): + """Test that no extra authorize params are set by default.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert provider._extra_authorize_params in (None, {}) + + def test_extra_authorize_params_passed_through(self, memory_storage: MemoryStore): + """Test that extra authorize params are forwarded.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + extra_authorize_params={"prompt": "login"}, + client_storage=memory_storage, + ) + + assert provider._extra_authorize_params == {"prompt": "login"} + + def test_valid_scopes_passed_through(self, memory_storage: MemoryStore): + """Test that valid_scopes is passed to OAuthProxy.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + required_scopes=["openid"], + valid_scopes=["openid", "email", "profile", "public_metadata"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + reg_options = provider.client_registration_options + assert reg_options is not None + assert reg_options.valid_scopes is not None + assert set(reg_options.valid_scopes) == { + "openid", + "email", + "profile", + "public_metadata", + } + + def test_issuer_url_defaults_to_base_url(self, memory_storage: MemoryStore): + """Test that issuer_url defaults to base_url when not provided.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert str(provider.issuer_url) == "https://myserver.com/" + + def test_custom_issuer_url(self, memory_storage: MemoryStore): + """Test that a custom issuer_url is used when provided.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com/mcp", + issuer_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert str(provider.issuer_url) == "https://myserver.com/" + + +class TestClerkTokenVerifier: + """Test ClerkTokenVerifier.verify_token() using introspection + userinfo.""" + + async def test_valid_token_basic(self, httpx_mock: HTTPXMock): + """A valid token returns an AccessToken with user claims from userinfo.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={ + "sub": "user_abc123", + "email": "user@example.com", + "email_verified": True, + "name": "Test User", + "picture": "https://img.clerk.com/photo.jpg", + "given_name": "Test", + "family_name": "User", + "preferred_username": "testuser", + "iss": f"https://{CLERK_DOMAIN}", + }, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={ + "active": True, + "scope": "openid email profile", + "aud": "clerk-client-id", + "exp": 9999999999, + }, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + ) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.client_id == "clerk-client-id" + assert result.scopes == ["openid", "email", "profile"] + assert result.expires_at == 9999999999 + assert result.claims["sub"] == "user_abc123" + assert result.claims["email"] == "user@example.com" + assert result.claims["name"] == "Test User" + assert result.claims["picture"] == "https://img.clerk.com/photo.jpg" + assert result.claims["given_name"] == "Test" + assert result.claims["family_name"] == "User" + assert result.claims["preferred_username"] == "testuser" + assert result.claims["aud"] == "clerk-client-id" + + async def test_invalid_token_returns_none(self, httpx_mock: HTTPXMock): + """Token marked inactive by introspection is rejected.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": False}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("expired-token") + + assert result is None + + async def test_missing_sub_returns_none(self, httpx_mock: HTTPXMock): + """Token with no 'sub' in introspection or userinfo is rejected.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True}, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"email": "user@example.com"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("token-without-sub") + + assert result is None + + async def test_introspection_inactive_token_returns_none( + self, httpx_mock: HTTPXMock + ): + """Token marked inactive by introspection is rejected before userinfo.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": False}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + ) + result = await verifier.verify_token("inactive-token") + + assert result is None + + async def test_introspection_missing_active_field_returns_none( + self, httpx_mock: HTTPXMock + ): + """RFC 7662 requires the 'active' field; a missing field is malformed and rejected.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"scope": "openid email profile", "aud": "clerk-client-id"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + ) + result = await verifier.verify_token("token-malformed-response") + + assert result is None + + async def test_introspection_failure_rejects_when_scopes_required( + self, httpx_mock: HTTPXMock + ): + """When introspection fails (non-200), token is rejected regardless of scopes.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + status_code=500, + json={"error": "internal_server_error"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + required_scopes=["openid", "email"], + ) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_empty_scopes_rejects_when_required(self, httpx_mock: HTTPXMock): + """When introspection returns no scopes and required_scopes are set, token is rejected.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": ""}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + required_scopes=["openid", "email", "profile"], + ) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_required_scopes_not_satisfied_returns_none( + self, httpx_mock: HTTPXMock + ): + """Token without required scopes is rejected before userinfo.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + required_scopes=["openid", "email", "profile"], + ) + result = await verifier.verify_token("token-missing-scopes") + + assert result is None + + async def test_uses_bearer_header_for_userinfo(self, httpx_mock: HTTPXMock): + """verify_token sends the token as a Bearer header to userinfo.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + await verifier.verify_token("my-access-token") + + requests = httpx_mock.get_requests() + userinfo_req = requests[1] + assert userinfo_req.headers["Authorization"] == "Bearer my-access-token" + + async def test_introspection_sends_client_credentials(self, httpx_mock: HTTPXMock): + """Introspection request sends credentials via HTTP Basic Auth when both are set.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "aud": "clerk-client-id"}, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + ) + await verifier.verify_token("my-access-token") + + requests = httpx_mock.get_requests() + introspect_req = requests[0] + body = introspect_req.content.decode() + assert "token=my-access-token" in body + assert introspect_req.headers.get("Authorization", "").startswith("Basic ") + + async def test_expires_at_from_introspection(self, httpx_mock: HTTPXMock): + """expires_at is set from the 'exp' claim in the introspection response.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "exp": 1700000000}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.expires_at == 1700000000 + + async def test_client_id_falls_back_to_sub(self, httpx_mock: HTTPXMock): + """When introspection has no aud/client_id, client_id falls back to sub.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.client_id == "user_abc123" + + async def test_aud_from_introspection_client_id_field(self, httpx_mock: HTTPXMock): + """When introspection returns client_id but not aud, client_id is used.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "client_id": "my-app-id"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.client_id == "my-app-id" + assert result.claims["aud"] == "my-app-id" + + async def test_no_required_scopes_accepts_any(self, httpx_mock: HTTPXMock): + """When no required_scopes are set, any valid token is accepted.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid custom_scope"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.scopes == ["openid", "custom_scope"] + + async def test_clerk_user_data_in_claims(self, httpx_mock: HTTPXMock): + """The full userinfo response is stored in clerk_user_data claim.""" + user_data = { + "sub": "user_abc123", + "email": "user@example.com", + "name": "Test User", + } + httpx_mock.add_response( + url=_USERINFO_RE, + json=user_data, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.claims["clerk_user_data"] == user_data + + async def test_network_error_returns_none(self, httpx_mock: HTTPXMock): + """Network errors during introspection return None instead of raising.""" + httpx_mock.add_exception( + httpx.ConnectError("Connection refused"), + url=_INTROSPECTION_RE, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_introspection_failure_rejects_without_required_scopes( + self, httpx_mock: HTTPXMock + ): + """Introspection failure (non-200) rejects the token even without required_scopes.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + status_code=500, + json={"error": "internal_server_error"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_audience_mismatch_returns_none(self, httpx_mock: HTTPXMock): + """Token with wrong audience is rejected before userinfo is called.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "aud": "wrong-client-id"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="my-client-id", + client_secret="my-client-secret", + ) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_audience_missing_returns_none_when_client_id_set( + self, httpx_mock: HTTPXMock + ): + """Token without audience is rejected before userinfo is called.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="my-client-id", + client_secret="my-client-secret", + ) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_audience_not_checked_without_client_id(self, httpx_mock: HTTPXMock): + """Without client_id configured, any audience is accepted.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "aud": "some-other-id"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.claims["aud"] == "some-other-id" diff --git a/tests/server/auth/providers/test_discord.py b/tests/server/auth/providers/test_discord.py index 509eb0826..edf3ffdc7 100644 --- a/tests/server/auth/providers/test_discord.py +++ b/tests/server/auth/providers/test_discord.py @@ -1,9 +1,11 @@ """Tests for Discord OAuth provider.""" +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from key_value.aio.stores.memory import MemoryStore -from fastmcp.server.auth.providers.discord import DiscordProvider +from fastmcp.server.auth.providers.discord import DiscordProvider, DiscordTokenVerifier @pytest.fixture @@ -27,6 +29,7 @@ class TestDiscordProvider: ) assert provider._upstream_client_id == "env_client_id" + assert provider._upstream_client_secret is not None assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123" assert str(provider.base_url) == "https://myserver.com/" @@ -81,3 +84,45 @@ class TestDiscordProvider: # Provider should initialize successfully with these scopes assert provider is not None + + def test_token_verifier_is_bound_to_provider_client_id( + self, memory_storage: MemoryStore + ): + """Test DiscordProvider binds token verifier to the configured client ID.""" + provider = DiscordProvider( + client_id="expected-client-id", + client_secret="GOCSPX-test123", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + verifier = provider._token_validator + assert isinstance(verifier, DiscordTokenVerifier) + assert verifier.expected_client_id == "expected-client-id" + + +class TestDiscordTokenVerifier: + """Test DiscordTokenVerifier behavior.""" + + async def test_rejects_token_from_different_discord_application(self): + """Token must be bound to configured Discord client_id.""" + verifier = DiscordTokenVerifier(expected_client_id="expected-app-id") + + mock_client = AsyncMock() + token_info_response = MagicMock() + token_info_response.status_code = 200 + token_info_response.json.return_value = { + "application": {"id": "different-app-id"}, + "user": {"id": "123"}, + "scopes": ["identify"], + } + mock_client.get.return_value = token_info_response + + with patch( + "fastmcp.server.auth.providers.discord.httpx.AsyncClient" + ) as mock_client_class: + mock_client_class.return_value.__aenter__.return_value = mock_client + result = await verifier.verify_token("token") + + assert result is None diff --git a/tests/server/auth/providers/test_github.py b/tests/server/auth/providers/test_github.py index fe2bbf031..11683f8b6 100644 --- a/tests/server/auth/providers/test_github.py +++ b/tests/server/auth/providers/test_github.py @@ -1,6 +1,6 @@ """Unit tests for GitHub OAuth provider.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from key_value.aio.stores.memory import MemoryStore @@ -35,6 +35,7 @@ class TestGitHubProvider: # Check that the provider was initialized correctly assert provider._upstream_client_id == "test_client" + assert provider._upstream_client_secret is not None assert provider._upstream_client_secret.get_secret_value() == "test_secret" assert ( str(provider.base_url) == "https://example.com/" @@ -99,8 +100,6 @@ class TestGitHubTokenVerifier: async def test_verify_token_success(self): """Test successful token verification.""" - from unittest.mock import AsyncMock - verifier = GitHubTokenVerifier(required_scopes=["user"]) # Mock the httpx.AsyncClient directly @@ -138,3 +137,173 @@ class TestGitHubTokenVerifier: assert result.scopes == ["user", "repo"] assert result.claims["login"] == "testuser" assert result.claims["name"] == "Test User" + + +def _mock_github_success(mock_client: AsyncMock) -> None: + """Configure *mock_client* to return a successful GitHub user + scopes response.""" + user_response = MagicMock() + user_response.status_code = 200 + user_response.json.return_value = { + "id": 12345, + "login": "testuser", + "name": "Test User", + "email": "test@example.com", + "avatar_url": "https://github.com/testuser.png", + } + + scopes_response = MagicMock() + scopes_response.status_code = 200 + scopes_response.headers = {"x-oauth-scopes": "user,repo"} + + mock_client.get.side_effect = [user_response, scopes_response] + + +def _mock_github_failure(mock_client: AsyncMock) -> None: + """Configure *mock_client* to return a 401 GitHub response.""" + fail_response = MagicMock() + fail_response.status_code = 401 + fail_response.text = "Bad credentials" + mock_client.get.return_value = fail_response + + +class TestGitHubTokenVerifierCaching: + """Test caching behaviour on GitHubTokenVerifier.""" + + def test_cache_disabled_by_default(self): + verifier = GitHubTokenVerifier() + assert not verifier._cache.enabled + + def test_cache_enabled_with_ttl(self): + verifier = GitHubTokenVerifier(cache_ttl_seconds=300) + assert verifier._cache.enabled + + async def test_cache_hit_avoids_second_api_call(self): + verifier = GitHubTokenVerifier( + required_scopes=["user"], + cache_ttl_seconds=300, + ) + + mock_client = AsyncMock() + + with patch( + "fastmcp.server.auth.providers.github.httpx.AsyncClient" + ) as mock_cls: + mock_cls.return_value.__aenter__.return_value = mock_client + + _mock_github_success(mock_client) + result1 = await verifier.verify_token("tok-1") + assert result1 is not None + assert mock_client.get.call_count == 2 # /user + /user/repos + + result2 = await verifier.verify_token("tok-1") + assert result2 is not None + assert result2.client_id == result1.client_id + assert mock_client.get.call_count == 2 # no additional calls + + async def test_cache_disabled_makes_every_call(self): + verifier = GitHubTokenVerifier( + required_scopes=["user"], + cache_ttl_seconds=0, + ) + + mock_client = AsyncMock() + + with patch( + "fastmcp.server.auth.providers.github.httpx.AsyncClient" + ) as mock_cls: + mock_cls.return_value.__aenter__.return_value = mock_client + + _mock_github_success(mock_client) + await verifier.verify_token("tok-1") + assert mock_client.get.call_count == 2 + + _mock_github_success(mock_client) + await verifier.verify_token("tok-1") + assert mock_client.get.call_count == 4 + + async def test_failures_are_not_cached(self): + verifier = GitHubTokenVerifier(cache_ttl_seconds=300) + + mock_client = AsyncMock() + + with patch( + "fastmcp.server.auth.providers.github.httpx.AsyncClient" + ) as mock_cls: + mock_cls.return_value.__aenter__.return_value = mock_client + + _mock_github_failure(mock_client) + result1 = await verifier.verify_token("bad-tok") + assert result1 is None + + _mock_github_success(mock_client) + result2 = await verifier.verify_token("bad-tok") + assert result2 is not None + + async def test_cached_result_is_defensive_copy(self): + verifier = GitHubTokenVerifier( + required_scopes=["user"], + cache_ttl_seconds=300, + ) + + mock_client = AsyncMock() + + with patch( + "fastmcp.server.auth.providers.github.httpx.AsyncClient" + ) as mock_cls: + mock_cls.return_value.__aenter__.return_value = mock_client + + _mock_github_success(mock_client) + result1 = await verifier.verify_token("tok-1") + assert result1 is not None + result1.claims["login"] = "MUTATED" + + result2 = await verifier.verify_token("tok-1") + assert result2 is not None + assert result2.claims["login"] == "testuser" + + async def test_scope_failure_skips_cache(self): + """Token verified with fallback scopes (scope API failed) should not be cached.""" + verifier = GitHubTokenVerifier(cache_ttl_seconds=300) + + mock_client = AsyncMock() + + user_response = MagicMock() + user_response.status_code = 200 + user_response.json.return_value = { + "id": 12345, + "login": "testuser", + "name": "Test User", + "email": "test@example.com", + "avatar_url": "https://github.com/testuser.png", + } + + scopes_response = MagicMock() + scopes_response.status_code = 500 + scopes_response.headers = {} + + with patch( + "fastmcp.server.auth.providers.github.httpx.AsyncClient" + ) as mock_cls: + mock_cls.return_value.__aenter__.return_value = mock_client + + mock_client.get.side_effect = [user_response, scopes_response] + result = await verifier.verify_token("tok-1") + assert result is not None + # Should NOT be cached because scope response was not 200 + assert not verifier._cache.enabled or len(verifier._cache._entries) == 0 + + def test_provider_passes_cache_params(self, memory_storage: MemoryStore): + provider = GitHubProvider( + client_id="cid", + client_secret="csec", + base_url="https://example.com", + cache_ttl_seconds=120, + max_cache_size=500, + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + verifier = provider._token_validator + assert isinstance(verifier, GitHubTokenVerifier) + assert verifier._cache.enabled + assert verifier._cache._ttl == 120 + assert verifier._cache._max_size == 500 diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py index 0f6bd6c89..4af76c52a 100644 --- a/tests/server/auth/providers/test_google.py +++ b/tests/server/auth/providers/test_google.py @@ -1,9 +1,18 @@ """Tests for Google OAuth provider.""" +import re +import time + import pytest from key_value.aio.stores.memory import MemoryStore +from pytest_httpx import HTTPXMock -from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.auth.providers.google import ( + GOOGLE_SCOPE_ALIASES, + GoogleProvider, + GoogleTokenVerifier, + _normalize_google_scope, +) @pytest.fixture @@ -27,6 +36,7 @@ class TestGoogleProvider: ) assert provider._upstream_client_id == "123456789.apps.googleusercontent.com" + assert provider._upstream_client_secret is not None assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123" assert str(provider.base_url) == "https://myserver.com/" @@ -134,3 +144,393 @@ class TestGoogleProvider: # Defaults should still be present assert provider._extra_authorize_params["access_type"] == "offline" assert provider._extra_authorize_params["prompt"] == "consent" + + def test_valid_scopes_passed_through(self, memory_storage: MemoryStore): + """Test that valid_scopes is passed to OAuthProxy.""" + provider = GoogleProvider( + client_id="123456789.apps.googleusercontent.com", + client_secret="GOCSPX-test123", + base_url="https://myserver.com", + required_scopes=["openid"], + valid_scopes=["openid", "email", "profile"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + reg_options = provider.client_registration_options + assert reg_options is not None + assert reg_options.valid_scopes is not None + # Shorthands should be normalized to full URIs + assert set(reg_options.valid_scopes) == { + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + } + + def test_valid_scopes_defaults_to_required(self, memory_storage: MemoryStore): + """Test that valid_scopes defaults to required_scopes when not provided.""" + provider = GoogleProvider( + client_id="123456789.apps.googleusercontent.com", + client_secret="GOCSPX-test123", + base_url="https://myserver.com", + required_scopes=["openid", "email"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + reg_options = provider.client_registration_options + assert reg_options is not None + assert reg_options.valid_scopes is not None + # Should fall back to the (normalized) required_scopes + assert set(reg_options.valid_scopes) == { + "openid", + "https://www.googleapis.com/auth/userinfo.email", + } + + +class TestGoogleScopeNormalization: + """Test Google scope shorthand normalization.""" + + @pytest.mark.parametrize( + "shorthand, expected", + [ + ("email", "https://www.googleapis.com/auth/userinfo.email"), + ("profile", "https://www.googleapis.com/auth/userinfo.profile"), + ("openid", "openid"), + ( + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.email", + ), + ( + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/calendar", + ), + ], + ) + def test_normalize_google_scope(self, shorthand: str, expected: str): + assert _normalize_google_scope(shorthand) == expected + + def test_verifier_normalizes_required_scopes(self): + """GoogleTokenVerifier should normalize shorthands in required_scopes.""" + verifier = GoogleTokenVerifier( + required_scopes=["openid", "email", "profile"], + ) + + assert set(verifier.required_scopes) == { + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + } + + def test_verifier_full_uris_unchanged(self): + """Full URIs should pass through normalization unchanged.""" + scopes = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + ] + verifier = GoogleTokenVerifier(required_scopes=scopes) + assert verifier.required_scopes == scopes + + def test_alias_map_is_bidirectional(self): + """Verify the alias map covers the known Google shorthands.""" + assert "email" in GOOGLE_SCOPE_ALIASES + assert "profile" in GOOGLE_SCOPE_ALIASES + + +# Regex patterns for URL matching (tokeninfo uses query params) +_TOKENINFO_RE = re.compile(r"https://oauth2\.googleapis\.com/tokeninfo") +_USERINFO_RE = re.compile(r"https://www\.googleapis\.com/oauth2/v2/userinfo") + + +class TestGoogleTokenVerifier: + """Test GoogleTokenVerifier.verify_token() using the tokeninfo endpoint.""" + + TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo" + USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" + + async def test_valid_token_openid_only(self, httpx_mock: HTTPXMock): + """A token with only openid scope is accepted; client_id comes from 'aud'.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "scope": "openid", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "12345"}, + ) + + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.client_id == "123.apps.googleusercontent.com" + assert result.scopes == ["openid"] + assert result.expires_at is not None + assert result.claims["sub"] == "12345" + assert result.claims["aud"] == "123.apps.googleusercontent.com" + + async def test_valid_token_with_email_and_profile(self, httpx_mock: HTTPXMock): + """A token with email+profile scope returns correct scopes and profile claims.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "email": "user@example.com", + "email_verified": "true", + "scope": "openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={ + "sub": "12345", + "email": "user@example.com", + "verified_email": True, + "name": "Test User", + "picture": "https://example.com/photo.jpg", + "given_name": "Test", + "family_name": "User", + "locale": "en", + }, + ) + + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.client_id == "123.apps.googleusercontent.com" + assert "openid" in result.scopes + assert "https://www.googleapis.com/auth/userinfo.email" in result.scopes + assert "https://www.googleapis.com/auth/userinfo.profile" in result.scopes + assert result.claims["email"] == "user@example.com" + assert result.claims["name"] == "Test User" + assert result.claims["picture"] == "https://example.com/photo.jpg" + + async def test_expired_or_invalid_token_returns_none(self, httpx_mock: HTTPXMock): + """HTTP 400 from tokeninfo endpoint causes verify_token to return None.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + status_code=400, + json={ + "error": "invalid_token", + "error_description": "Token has been expired or revoked.", + }, + ) + + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("expired-token") + + assert result is None + + async def test_missing_aud_returns_none(self, httpx_mock: HTTPXMock): + """A 200 response without 'aud' is rejected.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={"sub": "12345", "scope": "openid", "expires_in": "3600"}, + ) + + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("token-without-aud") + + assert result is None + + async def test_missing_sub_returns_none(self, httpx_mock: HTTPXMock): + """A 200 response without 'sub' is rejected.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "scope": "openid", + "expires_in": "3600", + }, + ) + + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("token-without-sub") + + assert result is None + + async def test_required_scopes_satisfied(self, httpx_mock: HTTPXMock): + """Token with required scopes passes the scope check.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "email": "user@example.com", + "scope": "openid https://www.googleapis.com/auth/userinfo.email", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "12345", "email": "user@example.com"}, + ) + + verifier = GoogleTokenVerifier( + required_scopes=["openid", "email"], + ) + result = await verifier.verify_token("valid-token") + + assert result is not None + + async def test_required_scopes_not_satisfied_returns_none( + self, httpx_mock: HTTPXMock + ): + """Token without required scopes is rejected.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "scope": "openid", + "expires_in": "3600", + }, + ) + + verifier = GoogleTokenVerifier( + required_scopes=[ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + ], + ) + result = await verifier.verify_token("token-missing-email-scope") + + assert result is None + + async def test_uses_query_param_not_bearer_header(self, httpx_mock: HTTPXMock): + """verify_token sends the token as a query parameter to tokeninfo, not a Bearer header.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "scope": "openid", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "12345"}, + ) + + verifier = GoogleTokenVerifier() + await verifier.verify_token("my-access-token") + + requests = httpx_mock.get_requests() + tokeninfo_req = requests[0] + assert "access_token=my-access-token" in str(tokeninfo_req.url) + assert "Authorization" not in tokeninfo_req.headers + + async def test_calls_tokeninfo_endpoint(self, httpx_mock: HTTPXMock): + """verify_token calls the tokeninfo endpoint, not the userinfo endpoint, for verification.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "scope": "openid", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "12345"}, + ) + + verifier = GoogleTokenVerifier() + await verifier.verify_token("valid-token") + + requests = httpx_mock.get_requests() + assert len(requests) >= 1 + assert "tokeninfo" in str(requests[0].url) + assert "oauth2.googleapis.com" in str(requests[0].url) + + async def test_expires_at_computed_from_expires_in(self, httpx_mock: HTTPXMock): + """expires_at is set from expires_in returned by tokeninfo.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "scope": "openid", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "12345"}, + ) + + before = int(time.time()) + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("valid-token") + after = int(time.time()) + + assert result is not None + assert result.expires_at is not None + assert before + 3600 <= result.expires_at <= after + 3600 + + async def test_profile_data_fetched_from_userinfo(self, httpx_mock: HTTPXMock): + """Profile data (name, picture, locale) comes from the v2 userinfo endpoint.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "scope": "openid https://www.googleapis.com/auth/userinfo.profile", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={ + "sub": "12345", + "name": "Test User", + "picture": "https://example.com/photo.jpg", + "given_name": "Test", + "family_name": "User", + "locale": "en", + }, + ) + + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.claims["name"] == "Test User" + assert result.claims["picture"] == "https://example.com/photo.jpg" + assert result.claims["given_name"] == "Test" + assert result.claims["family_name"] == "User" + assert result.claims["locale"] == "en" + + async def test_non_openid_scopes_checked_correctly(self, httpx_mock: HTTPXMock): + """Calendar scope is correctly checked from the tokeninfo scope string.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "scope": "openid https://www.googleapis.com/auth/calendar", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "12345"}, + ) + + verifier = GoogleTokenVerifier( + required_scopes=["openid", "https://www.googleapis.com/auth/calendar"], + ) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert "https://www.googleapis.com/auth/calendar" in result.scopes diff --git a/tests/server/auth/providers/test_http_client.py b/tests/server/auth/providers/test_http_client.py index 34c118f38..fa6126183 100644 --- a/tests/server/auth/providers/test_http_client.py +++ b/tests/server/auth/providers/test_http_client.py @@ -245,7 +245,10 @@ class TestDiscordHttpClient: from fastmcp.server.auth.providers.discord import DiscordTokenVerifier client = httpx.AsyncClient() - verifier = DiscordTokenVerifier(http_client=client) + verifier = DiscordTokenVerifier( + expected_client_id="test-client-id", + http_client=client, + ) assert verifier._http_client is client diff --git a/tests/server/auth/providers/test_introspection.py b/tests/server/auth/providers/test_introspection.py index 793570987..46dcd2f0d 100644 --- a/tests/server/auth/providers/test_introspection.py +++ b/tests/server/auth/providers/test_introspection.py @@ -554,8 +554,7 @@ class TestIntrospectionCaching: client_id="test-client", client_secret="test-secret", ) - assert verifier._cache_ttl == 0 # Disabled by default - assert verifier._max_cache_size == 10000 + assert not verifier._cache.enabled def test_custom_cache_settings(self): """Test that cache settings can be customized.""" @@ -566,8 +565,8 @@ class TestIntrospectionCaching: cache_ttl_seconds=60, max_cache_size=500, ) - assert verifier._cache_ttl == 60 - assert verifier._max_cache_size == 500 + assert verifier._cache._ttl == 60 + assert verifier._cache._max_size == 500 def test_cache_disabled_with_zero_ttl(self): """Test that cache is disabled when TTL is 0 or None.""" @@ -578,7 +577,7 @@ class TestIntrospectionCaching: client_secret="test-secret", cache_ttl_seconds=0, ) - assert verifier._cache_ttl == 0 + assert not verifier._cache.enabled # Explicit None (same as default) verifier2 = IntrospectionTokenVerifier( @@ -587,25 +586,20 @@ class TestIntrospectionCaching: client_secret="test-secret", cache_ttl_seconds=None, ) - assert verifier2._cache_ttl == 0 + assert not verifier2._cache.enabled - async def test_cache_disabled_with_zero_or_negative_max_size( - self, httpx_mock: HTTPXMock - ): - """Test that cache is disabled when max_cache_size is 0 or negative.""" - # Add two responses for the two verifiers - for _ in range(2): - httpx_mock.add_response( - url="https://auth.example.com/oauth/introspect", - method="POST", - json={ - "active": True, - "client_id": "user-123", - "scope": "read", - }, - ) + async def test_cache_disabled_with_zero_max_size(self, httpx_mock: HTTPXMock): + """Test that cache is disabled when max_cache_size is 0.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={ + "active": True, + "client_id": "user-123", + "scope": "read", + }, + ) - # Zero max_cache_size should disable caching (not raise StopIteration) verifier = IntrospectionTokenVerifier( introspection_url="https://auth.example.com/oauth/introspect", client_id="test-client", @@ -617,16 +611,16 @@ class TestIntrospectionCaching: assert result is not None assert result.client_id == "user-123" - # Negative max_cache_size should also disable caching - verifier2 = IntrospectionTokenVerifier( - introspection_url="https://auth.example.com/oauth/introspect", - client_id="test-client", - client_secret="test-secret", - cache_ttl_seconds=300, - max_cache_size=-1, - ) - result2 = await verifier2.verify_token("test-token") - assert result2 is not None + def test_negative_max_cache_size_raises(self): + """Negative max_cache_size is a caller bug and should raise.""" + with pytest.raises(ValueError, match="max_cache_size must be non-negative"): + IntrospectionTokenVerifier( + introspection_url="https://auth.example.com/oauth/introspect", + client_id="test-client", + client_secret="test-secret", + cache_ttl_seconds=300, + max_cache_size=-1, + ) async def test_cache_hit_returns_cached_result( self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock @@ -854,9 +848,9 @@ class TestIntrospectionCaching: def test_token_hashing(self, verifier_with_cache: IntrospectionTokenVerifier): """Test that tokens are hashed consistently.""" - hash1 = verifier_with_cache._hash_token("test-token") - hash2 = verifier_with_cache._hash_token("test-token") - hash3 = verifier_with_cache._hash_token("different-token") + hash1 = verifier_with_cache._cache._hash_token("test-token") + hash2 = verifier_with_cache._cache._hash_token("test-token") + hash3 = verifier_with_cache._cache._hash_token("different-token") # Same token produces same hash assert hash1 == hash2 @@ -885,8 +879,8 @@ class TestIntrospectionCaching: await verifier_with_cache.verify_token("test-token") # Check that cache entry uses the shorter expiration - cache_key = verifier_with_cache._hash_token("test-token") - entry = verifier_with_cache._cache[cache_key] + cache_key = verifier_with_cache._cache._hash_token("test-token") + entry = verifier_with_cache._cache._entries[cache_key] # Cache expiration should be at or before token expiration assert entry.expires_at <= short_exp @@ -917,8 +911,8 @@ class TestIntrospectionCaching: assert len(httpx_mock.get_requests()) == 1 # Expire the cache entry manually - cache_key = verifier._hash_token("test-token") - verifier._cache[cache_key].expires_at = time.time() - 1 + cache_key = verifier._cache._hash_token("test-token") + verifier._cache._entries[cache_key].expires_at = time.time() - 1 # Second call — cache miss, new introspection await verifier.verify_token("test-token") @@ -944,15 +938,15 @@ class TestIntrospectionCaching: # Fill cache to capacity await verifier.verify_token("token-0") await verifier.verify_token("token-1") - assert len(verifier._cache) == 2 + assert len(verifier._cache._entries) == 2 # Third token should evict the oldest entry await verifier.verify_token("token-2") - assert len(verifier._cache) == 2 + assert len(verifier._cache._entries) == 2 # token-0 should have been evicted (FIFO) - hash_0 = verifier._hash_token("token-0") - assert hash_0 not in verifier._cache + hash_0 = verifier._cache._hash_token("token-0") + assert hash_0 not in verifier._cache._entries class TestIntrospectionTokenVerifierIntegration: diff --git a/tests/server/auth/providers/test_propelauth.py b/tests/server/auth/providers/test_propelauth.py index 9d70496fc..f06efe685 100644 --- a/tests/server/auth/providers/test_propelauth.py +++ b/tests/server/auth/providers/test_propelauth.py @@ -133,8 +133,8 @@ class TestPropelAuthProvider: ) assert isinstance(provider.token_verifier, IntrospectionTokenVerifier) - assert provider.token_verifier._cache_ttl == 300 - assert provider.token_verifier._max_cache_size == 500 + assert provider.token_verifier._cache._ttl == 300 + assert provider.token_verifier._cache._max_size == 500 def test_token_introspection_overrides_http_client(self): """Test that http_client override is passed to the verifier.""" diff --git a/tests/server/auth/providers/test_supabase.py b/tests/server/auth/providers/test_supabase.py index 5182ca5e5..1537bff04 100644 --- a/tests/server/auth/providers/test_supabase.py +++ b/tests/server/auth/providers/test_supabase.py @@ -93,7 +93,7 @@ class TestSupabaseProvider: @pytest.mark.parametrize( "algorithm", - ["HS256", "RS256", "ES256"], + ["RS256", "ES256"], ) def test_algorithm_configuration(self, algorithm): """Test that algorithm can be configured for different JWT signing methods.""" @@ -106,6 +106,15 @@ class TestSupabaseProvider: assert isinstance(provider.token_verifier, JWTVerifier) assert provider.token_verifier.algorithm == algorithm + def test_algorithm_rejects_hs256(self): + """Test that HS256 is rejected for Supabase's JWKS-based verifier.""" + with pytest.raises(ValueError, match="cannot be used with jwks_uri"): + SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://myserver.com", + algorithm="HS256", # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + ) + def test_algorithm_default_es256(self): """Test that algorithm defaults to ES256 when not specified.""" provider = SupabaseProvider( diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index 594f2e5b5..cc6ac742a 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -5,10 +5,15 @@ from urllib.parse import urlparse import httpx import pytest from key_value.aio.stores.memory import MemoryStore +from pytest_httpx import HTTPXMock from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport -from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSProvider +from fastmcp.server.auth.providers.workos import ( + AuthKitProvider, + WorkOSProvider, + WorkOSTokenVerifier, +) from fastmcp.utilities.tests import HeadlessOAuth, run_server_async @@ -34,6 +39,7 @@ class TestWorkOSProvider: ) assert provider._upstream_client_id == "client_test123" + assert provider._upstream_client_secret is not None assert provider._upstream_client_secret.get_secret_value() == "secret_test456" assert str(provider.base_url) == "https://myserver.com/" @@ -165,3 +171,50 @@ class TestAuthKitProvider: # assert tools is not None # assert len(tools) > 0 # assert "add" in tools + + +class TestWorkOSTokenVerifierScopes: + async def test_verify_token_rejects_missing_required_scopes( + self, httpx_mock: HTTPXMock + ): + httpx_mock.add_response( + url="https://test.authkit.app/oauth2/userinfo", + status_code=200, + json={ + "sub": "user_123", + "email": "user@example.com", + "scope": "openid profile", + }, + ) + + verifier = WorkOSTokenVerifier( + authkit_domain="https://test.authkit.app", + required_scopes=["read:secrets"], + ) + + result = await verifier.verify_token("token") + + assert result is None + + async def test_verify_token_returns_actual_token_scopes( + self, httpx_mock: HTTPXMock + ): + httpx_mock.add_response( + url="https://test.authkit.app/oauth2/userinfo", + status_code=200, + json={ + "sub": "user_123", + "email": "user@example.com", + "scope": "openid profile read:secrets", + }, + ) + + verifier = WorkOSTokenVerifier( + authkit_domain="https://test.authkit.app", + required_scopes=["read:secrets"], + ) + + result = await verifier.verify_token("token") + + assert result is not None + assert result.scopes == ["openid", "profile", "read:secrets"] diff --git a/tests/server/auth/test_authorization.py b/tests/server/auth/test_authorization.py index f6e86aeef..64e78599e 100644 --- a/tests/server/auth/test_authorization.py +++ b/tests/server/auth/test_authorization.py @@ -18,6 +18,8 @@ from fastmcp.server.auth import ( run_auth_checks, ) from fastmcp.server.middleware import AuthMiddleware +from fastmcp.server.transforms import ToolTransform +from fastmcp.tools.tool_transform import ToolTransformConfig, TransformedTool # ============================================================================= # Test helpers @@ -677,8 +679,6 @@ class TestAsyncAuthIntegration: class TestTransformedToolAuth: async def test_transformed_tool_preserves_auth(self): """Transformed tools should inherit auth from parent.""" - from fastmcp.tools.tool_transform import TransformedTool - mcp = FastMCP() @mcp.tool(auth=require_scopes("test")) @@ -702,8 +702,6 @@ class TestTransformedToolAuth: async def test_transformed_tool_filtered_without_token(self): """Transformed tools with auth should be filtered without token.""" - from fastmcp.tools.tool_transform import ToolTransformConfig - mcp = FastMCP() @mcp.tool(auth=require_scopes("test")) @@ -711,8 +709,10 @@ class TestTransformedToolAuth: return str(x) # Add transformation - mcp.add_tool_transformation( - "protected_tool", ToolTransformConfig(name="renamed_protected") + mcp.add_transform( + ToolTransform( + {"protected_tool": ToolTransformConfig(name="renamed_protected")} + ) ) # Without token, transformed tool should not be visible @@ -721,8 +721,6 @@ class TestTransformedToolAuth: async def test_transformed_tool_visible_with_token(self): """Transformed tools with auth should be visible with token.""" - from fastmcp.tools.tool_transform import ToolTransformConfig - mcp = FastMCP() @mcp.tool(auth=require_scopes("test")) @@ -730,8 +728,10 @@ class TestTransformedToolAuth: return str(x) # Add transformation - mcp.add_tool_transformation( - "protected_tool", ToolTransformConfig(name="renamed_protected") + mcp.add_transform( + ToolTransform( + {"protected_tool": ToolTransformConfig(name="renamed_protected")} + ) ) # With token, transformed tool should be visible diff --git a/tests/server/auth/test_cimd.py b/tests/server/auth/test_cimd.py index 3ed99857a..3cd05c2d1 100644 --- a/tests/server/auth/test_cimd.py +++ b/tests/server/auth/test_cimd.py @@ -65,7 +65,7 @@ class TestCIMDDocument: CIMDDocument( client_id=AnyHttpUrl("https://example.com/client.json"), redirect_uris=["http://localhost:3000/callback"], - token_endpoint_auth_method="client_secret_basic", # type: ignore[arg-type] - testing invalid value + token_endpoint_auth_method="client_secret_basic", # type: ignore[arg-type] - testing invalid value # ty:ignore[invalid-argument-type] ) # Literal type rejects invalid values before custom validator assert "token_endpoint_auth_method" in str(exc_info.value) @@ -76,7 +76,7 @@ class TestCIMDDocument: CIMDDocument( client_id=AnyHttpUrl("https://example.com/client.json"), redirect_uris=["http://localhost:3000/callback"], - token_endpoint_auth_method="client_secret_post", # type: ignore[arg-type] - testing invalid value + token_endpoint_auth_method="client_secret_post", # type: ignore[arg-type] - testing invalid value # ty:ignore[invalid-argument-type] ) assert "token_endpoint_auth_method" in str(exc_info.value) @@ -86,7 +86,7 @@ class TestCIMDDocument: CIMDDocument( client_id=AnyHttpUrl("https://example.com/client.json"), redirect_uris=["http://localhost:3000/callback"], - token_endpoint_auth_method="client_secret_jwt", # type: ignore[arg-type] - testing invalid value + token_endpoint_auth_method="client_secret_jwt", # type: ignore[arg-type] - testing invalid value # ty:ignore[invalid-argument-type] ) assert "token_endpoint_auth_method" in str(exc_info.value) @@ -179,6 +179,16 @@ class TestCIMDFetcher: assert fetcher.validate_redirect_uri(doc, "http://localhost:8080/callback") assert not fetcher.validate_redirect_uri(doc, "http://localhost:3000/other") + def test_validate_redirect_uri_loopback_no_port(self, fetcher: CIMDFetcher): + """RFC 8252 §7.3: loopback URI without port should match any port.""" + doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=["http://localhost/callback", "http://127.0.0.1/callback"], + ) + assert fetcher.validate_redirect_uri(doc, "http://localhost:51353/callback") + assert fetcher.validate_redirect_uri(doc, "http://127.0.0.1:3000/callback") + assert not fetcher.validate_redirect_uri(doc, "http://localhost:51353/other") + class TestCIMDFetcherHTTP: """Tests for CIMDFetcher HTTP fetching (using httpx mock). diff --git a/tests/server/auth/test_jwt_issuer.py b/tests/server/auth/test_jwt_issuer.py index 79bbb02de..9bebf1132 100644 --- a/tests/server/auth/test_jwt_issuer.py +++ b/tests/server/auth/test_jwt_issuer.py @@ -132,7 +132,7 @@ class TestJWTIssuer: expires_in=60 * 60 * 24 * 30, # 30 days ) - payload = issuer.verify_token(token) + payload = issuer.verify_token(token, expected_token_use="refresh") assert payload["client_id"] == "client-abc" assert payload["token_use"] == "refresh" assert payload["jti"] == "refresh-token-id" @@ -280,8 +280,31 @@ class TestJWTIssuer: upstream_claims=upstream_claims, ) - payload = issuer.verify_token(token) + payload = issuer.verify_token(token, expected_token_use="refresh") assert "upstream_claims" in payload assert payload["upstream_claims"]["sub"] == "user-123" assert payload["upstream_claims"]["name"] == "Test User" assert payload["token_use"] == "refresh" + + def test_verify_token_rejects_refresh_token_as_access(self, issuer): + """Refresh tokens must not be accepted when expecting access tokens.""" + token = issuer.issue_refresh_token( + client_id="client-abc", + scopes=["read"], + jti="refresh-token-id", + expires_in=60 * 60 * 24 * 30, + ) + + with pytest.raises(JoseError, match="Token type mismatch"): + issuer.verify_token(token) + + def test_verify_token_rejects_access_token_as_refresh(self, issuer): + """Access tokens must not be accepted when expecting refresh tokens.""" + token = issuer.issue_access_token( + client_id="client-abc", + scopes=["read"], + jti="token-id", + ) + + with pytest.raises(JoseError, match="Token type mismatch"): + issuer.verify_token(token, expected_token_use="refresh") diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py index 19b0f9370..586d44d5b 100644 --- a/tests/server/auth/test_jwt_provider.py +++ b/tests/server/auth/test_jwt_provider.py @@ -50,7 +50,7 @@ class SymmetricKeyHelper: header = {"alg": algorithm} # Create payload - payload = { + payload: dict[str, str | int | list[str]] = { "sub": subject, "iss": issuer, "iat": int(time.time()), @@ -201,6 +201,15 @@ class TestSymmetricKeyJWT: assert provider.algorithm == "HS256" assert provider.jwks_uri is None + def test_initialization_rejects_hs_algorithm_with_jwks_uri(self): + """Test that HMAC algorithms cannot be used with JWKS URI.""" + with pytest.raises(ValueError, match="cannot be used with jwks_uri"): + JWTVerifier( + jwks_uri="https://test.example.com/.well-known/jwks.json", + issuer="https://test.example.com", + algorithm="HS256", + ) + def test_initialization_with_different_symmetric_algorithms( self, symmetric_key_helper: SymmetricKeyHelper ): @@ -215,6 +224,32 @@ class TestSymmetricKeyJWT: ) assert provider.algorithm == algorithm + def test_symmetric_algorithm_rejects_jwks_uri(self): + """HS* algorithms must not be configured with JWKS/public key endpoints.""" + with pytest.raises(ValueError, match="cannot be used with jwks_uri"): + JWTVerifier( + jwks_uri="https://test.example.com/.well-known/jwks.json", + issuer="https://test.example.com", + algorithm="HS256", + ) + + def test_symmetric_algorithm_rejects_pem_public_key(self, rsa_key_pair: RSAKeyPair): + """HS* algorithms must use a shared secret, not PEM public key material.""" + with pytest.raises(ValueError, match="require a shared secret"): + JWTVerifier( + public_key=rsa_key_pair.public_key, + issuer="https://test.example.com", + algorithm="HS256", + ) + + def test_symmetric_algorithm_accepts_bytes_secret(self): + """HS* algorithms accept bytes secrets without TypeError.""" + verifier = JWTVerifier( + public_key=b"secret", + algorithm="HS256", + ) + assert verifier.algorithm == "HS256" + async def test_valid_symmetric_token_validation( self, symmetric_key_helper: SymmetricKeyHelper, symmetric_provider: JWTVerifier ): @@ -554,7 +589,7 @@ class TestBearerTokenJWKS: httpx_mock: HTTPXMock, mock_dns, ): - mock_jwks_data["keys"] = [ # type: ignore[typeddict-item] + mock_jwks_data["keys"] = [ { "kid": "test-key-1", "alg": "RS256", diff --git a/tests/server/auth/test_oauth_consent_flow.py b/tests/server/auth/test_oauth_consent_flow.py index 7a65df297..025eb3452 100644 --- a/tests/server/auth/test_oauth_consent_flow.py +++ b/tests/server/auth/test_oauth_consent_flow.py @@ -462,6 +462,45 @@ class TestCSRFProtection: ) +class TestCSRFDoubleSubmit: + """Tests for CSRF double-submit cookie validation (GHSA-rww4-4w9c-7733 bypass).""" + + async def test_consent_rejected_without_csrf_cookie(self, oauth_proxy_with_storage): + """Submitting a valid CSRF token without the matching cookie should be rejected. + + This prevents an attacker from using their own tx_id/csrf_token to CSRF + the victim's browser into approving consent. + """ + txn_id, _ = await _start_flow( + oauth_proxy_with_storage, + "csrf-double-submit-client", + "http://localhost:9090/callback", + ) + + app = Starlette(routes=oauth_proxy_with_storage.get_routes()) + with TestClient(app) as test_client: + # Visit consent page to populate the transaction with a CSRF token + consent_resp = test_client.get(f"/consent?txn_id={txn_id}") + assert consent_resp.status_code == 200 + csrf_token = _extract_csrf(consent_resp.text) + assert csrf_token + + # Simulate the attack: use a FRESH client (no cookies from the consent + # page) to submit the form with a valid CSRF token — as if the attacker + # tricked the victim's browser into POSTing their tx_id/csrf_token. + with TestClient(app) as attacker_client: + response = attacker_client.post( + "/consent", + data={ + "action": "approve", + "txn_id": txn_id, + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + assert response.status_code == 403 + + class TestStoragePersistence: """Tests for state persistence across storage backends.""" diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index 47ecfbe8d..bf750b905 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -22,7 +22,7 @@ class MockTokenVerifier(TokenVerifier): def __init__(self): self.required_scopes = [] - async def verify_token(self, token: str) -> dict | None: # type: ignore[override] + async def verify_token(self, token: str) -> dict | None: # type: ignore[override] # ty:ignore[invalid-method-override] return {"sub": "test-user"} @@ -188,6 +188,30 @@ class TestProxyDCRClient: with pytest.raises(InvalidRedirectUriError): client.validate_redirect_uri(None) + def test_cimd_loopback_no_port_matches_dynamic_port(self): + """RFC 8252 §7.3: CIMD redirect_uris without port match any loopback port.""" + cimd_doc = CIMDDocument( + client_id=AnyHttpUrl("https://example.com/client.json"), + redirect_uris=[ + "http://localhost/callback", + "http://127.0.0.1/callback", + ], + ) + client = ProxyDCRClient( + client_id="https://example.com/client.json", + client_secret=None, + redirect_uris=None, + cimd_document=cimd_doc, + ) + + # Dynamic ports should be accepted per RFC 8252 §7.3 + assert client.validate_redirect_uri(AnyUrl("http://localhost:51353/callback")) + assert client.validate_redirect_uri(AnyUrl("http://127.0.0.1:3000/callback")) + + # Wrong path should still be rejected + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://localhost:51353/other")) + def test_cimd_empty_proxy_allowlist_rejects_redirect_uri(self): """An explicit empty proxy allowlist should reject all CIMD redirect URIs.""" cimd_doc = CIMDDocument( diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py index e3dd95e1c..8b629d59f 100644 --- a/tests/server/auth/test_oidc_proxy.py +++ b/tests/server/auth/test_oidc_proxy.py @@ -436,6 +436,7 @@ def validate_proxy(mock_get, proxy, oidc_config): assert proxy._upstream_authorization_endpoint == TEST_AUTHORIZATION_ENDPOINT assert proxy._upstream_token_endpoint == TEST_TOKEN_ENDPOINT assert proxy._upstream_client_id == TEST_CLIENT_ID + assert proxy._upstream_client_secret is not None assert proxy._upstream_client_secret.get_secret_value() == TEST_CLIENT_SECRET assert str(proxy.base_url) == str(TEST_BASE_URL) assert proxy.oidc_config == oidc_config @@ -623,11 +624,14 @@ class TestOIDCProxyInitialization: ) mock_get.return_value = oidc_config - with pytest.raises(ValueError, match="Missing required client secret"): + with pytest.raises( + ValueError, + match="Either client_secret or jwt_signing_key must be provided", + ): OIDCProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, - client_secret=None, # type: ignore + client_secret=None, base_url=TEST_BASE_URL, ) diff --git a/tests/server/auth/test_oidc_proxy_token.py b/tests/server/auth/test_oidc_proxy_token.py index 57083d50d..c261fb02e 100644 --- a/tests/server/auth/test_oidc_proxy_token.py +++ b/tests/server/auth/test_oidc_proxy_token.py @@ -329,3 +329,97 @@ class TestVerifyIdToken: "read", "write", ] + + +class TestUsesAlternateVerification: + """Tests for _uses_alternate_verification intent-based flag.""" + + def test_disabled_by_default(self, valid_oidc_configuration_dict): + """OIDCProxy without verify_id_token returns False.""" + 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, + jwt_signing_key="test-secret", + ) + + assert proxy._uses_alternate_verification() is False + + def test_enabled_with_verify_id_token(self, valid_oidc_configuration_dict): + """OIDCProxy with verify_id_token=True returns True.""" + 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, + jwt_signing_key="test-secret", + verify_id_token=True, + ) + + assert proxy._uses_alternate_verification() is True + + def test_scope_patch_applied_when_tokens_identical( + self, valid_oidc_configuration_dict + ): + """Regression test: scopes must be patched even when id_token and + access_token carry the same JWT value (fixes #3461).""" + 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, + jwt_signing_key="test-secret", + verify_id_token=True, + ) + + # Same JWT for both access_token and id_token — the scenario + # that triggered the bug. + same_jwt = "eyJhbGciOiJSUzI1NiJ9.identical-token" + token_set = UpstreamTokenSet( + upstream_token_id="test-id", + access_token=same_jwt, + refresh_token=None, + refresh_token_expires_at=None, + expires_at=9999999999.0, + token_type="Bearer", + scope="openid offline_access", + client_id="test-client", + created_at=1000000000.0, + raw_token_data={ + "access_token": same_jwt, + "id_token": same_jwt, + }, + ) + + # _uses_alternate_verification should be True regardless of + # token value equality + assert proxy._uses_alternate_verification() is True + # _get_verification_token returns the id_token (same value) + assert proxy._get_verification_token(token_set) == same_jwt + # The key point: even though the tokens are equal, the intent + # flag ensures load_access_token will patch scopes diff --git a/tests/server/auth/test_redirect_validation.py b/tests/server/auth/test_redirect_validation.py index 10945d2fb..18d885023 100644 --- a/tests/server/auth/test_redirect_validation.py +++ b/tests/server/auth/test_redirect_validation.py @@ -168,6 +168,64 @@ class TestSecurityBypass: assert not matches_allowed_pattern("http://example.com:3000/callback", pattern) +class TestLoopbackPortMatching: + """Test RFC 8252 §7.3: loopback URIs with no port in pattern match any port.""" + + def test_localhost_no_port_matches_any_port(self): + """Pattern http://localhost/callback should match any port on localhost.""" + pattern = "http://localhost/callback" + assert matches_allowed_pattern("http://localhost:51353/callback", pattern) + assert matches_allowed_pattern("http://localhost:3000/callback", pattern) + assert matches_allowed_pattern("http://localhost:80/callback", pattern) + + def test_localhost_no_port_no_path_matches_any_port(self): + """Pattern http://localhost should match any port on localhost.""" + pattern = "http://localhost" + assert matches_allowed_pattern("http://localhost:51353", pattern) + assert matches_allowed_pattern("http://localhost:3000/callback", pattern) + + def test_127_0_0_1_no_port_matches_any_port(self): + """Pattern http://127.0.0.1/callback should match any port on 127.0.0.1.""" + pattern = "http://127.0.0.1/callback" + assert matches_allowed_pattern("http://127.0.0.1:51353/callback", pattern) + assert matches_allowed_pattern("http://127.0.0.1:3000/callback", pattern) + + def test_ipv6_loopback_no_port_matches_any_port(self): + """Pattern http://[::1]/callback should match any port on [::1].""" + pattern = "http://[::1]/callback" + assert matches_allowed_pattern("http://[::1]:51353/callback", pattern) + assert matches_allowed_pattern("http://[::1]:3000/callback", pattern) + + def test_non_loopback_no_port_requires_default_port(self): + """Non-loopback patterns without port should still require default port.""" + pattern = "http://example.com/callback" + # Should only match port 80 (default for HTTP) + assert matches_allowed_pattern("http://example.com/callback", pattern) + assert matches_allowed_pattern("http://example.com:80/callback", pattern) + assert not matches_allowed_pattern("http://example.com:3000/callback", pattern) + + def test_loopback_explicit_port_requires_exact_match(self): + """Loopback patterns with an explicit port should still require exact match.""" + pattern = "http://localhost:8080/callback" + assert matches_allowed_pattern("http://localhost:8080/callback", pattern) + assert not matches_allowed_pattern("http://localhost:3000/callback", pattern) + + def test_loopback_no_port_still_checks_scheme(self): + """Scheme must still match even for loopback URIs.""" + pattern = "http://localhost/callback" + assert not matches_allowed_pattern("https://localhost:3000/callback", pattern) + + def test_loopback_no_port_still_checks_host(self): + """Host must still match even for loopback URIs.""" + pattern = "http://localhost/callback" + assert not matches_allowed_pattern("http://example.com:3000/callback", pattern) + + def test_loopback_no_port_still_checks_path(self): + """Path must still match even for loopback URIs.""" + pattern = "http://localhost/callback" + assert not matches_allowed_pattern("http://localhost:3000/other", pattern) + + class TestDefaultPatterns: """Test the default localhost patterns constant.""" diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index e637af269..f1a1e6f47 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -2,10 +2,11 @@ import json import pytest from mcp.types import TextContent, TextResourceContents +from starlette.requests import Request from fastmcp.client import Client from fastmcp.client.transports import SSETransport, StreamableHttpTransport -from fastmcp.server.dependencies import get_http_request +from fastmcp.server.dependencies import CurrentHeaders, CurrentRequest, get_http_request from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_async @@ -166,3 +167,53 @@ async def test_get_http_headers_excludes_content_type(sse_server: str): # Custom headers should be included assert "x-custom-header" in headers assert headers["x-custom-header"] == "should-be-included" + + +async def test_background_task_can_read_snapshotted_request_headers(): + """Background tools can still access request headers via get_http_request().""" + server = FastMCP() + + @server.tool(task=True) + async def check_request_header() -> str: + request = get_http_request() + return request.headers.get("x-tenant-id", "missing") + + async with run_server_async(server, transport="sse") as url: + async with Client( + transport=SSETransport(url, headers={"X-Tenant-ID": "tenant-123"}) + ) as client: + task = await client.call_tool("check_request_header", task=True) + result = await task.result() + assert result.data == "tenant-123" + + +async def test_background_task_current_http_dependencies_restore_headers(): + """CurrentHeaders/CurrentRequest work in task workers without explicit Context.""" + server = FastMCP() + + @server.tool(task=True) + async def check_headers( + headers: dict[str, str] = CurrentHeaders(), + request: Request = CurrentRequest(), + ) -> dict[str, str]: + return { + "authorization": headers.get("authorization", "missing"), + "tenant": request.headers.get("x-tenant-id", "missing"), + } + + async with run_server_async(server, transport="sse") as url: + async with Client( + transport=SSETransport( + url, + headers={ + "Authorization": "Bearer tenant-token", + "X-Tenant-ID": "tenant-456", + }, + ) + ) as client: + task = await client.call_tool("check_headers", task=True) + result = await task.result() + assert result.data == { + "authorization": "Bearer tenant-token", + "tenant": "tenant-456", + } diff --git a/tests/server/http/test_http_middleware.py b/tests/server/http/test_http_middleware.py index 3d2ad1903..94b263d88 100644 --- a/tests/server/http/test_http_middleware.py +++ b/tests/server/http/test_http_middleware.py @@ -55,7 +55,6 @@ async def test_sse_app_with_custom_middleware(): server = FastMCP(name="TestServer") # Create custom middleware - # TODO(ty): remove when Starlette Middleware typing is supported custom_middleware = [ Middleware( HeaderMiddleware, # type: ignore[arg-type] @@ -88,7 +87,6 @@ async def test_streamable_http_app_with_custom_middleware(): server = FastMCP(name="TestServer") # Create custom middleware - # TODO(ty): remove when Starlette Middleware typing is supported custom_middleware = [ Middleware( HeaderMiddleware, # type: ignore[arg-type] @@ -121,7 +119,6 @@ async def test_create_sse_app_with_custom_middleware(): server = FastMCP(name="TestServer") # Create custom middleware - # TODO(ty): remove when Starlette Middleware typing is supported custom_middleware = [ Middleware( RequestModifierMiddleware, # type: ignore[arg-type] @@ -161,7 +158,6 @@ async def test_create_streamable_http_app_with_custom_middleware(): server = FastMCP(name="TestServer") # Create custom middleware - # TODO(ty): remove when Starlette Middleware typing is supported custom_middleware = [ Middleware( RequestModifierMiddleware, # type: ignore[arg-type] @@ -200,7 +196,6 @@ async def test_multiple_middleware_ordering(): server = FastMCP(name="TestServer") # Create multiple middleware - # TODO(ty): remove when Starlette Middleware typing is supported custom_middleware = [ Middleware( HeaderMiddleware, # type: ignore[arg-type] diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index 858089251..c8d444006 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -27,17 +27,20 @@ from pydantic import AnyUrl, BaseModel from fastmcp import Context, FastMCP from fastmcp.client.client import CallToolResult, Client from fastmcp.client.transports import FastMCPTransport +from fastmcp.prompts.base import Message, Prompt from fastmcp.prompts.function_prompt import FunctionPrompt -from fastmcp.prompts.prompt import Message, Prompt -from fastmcp.resources.resource import Resource +from fastmcp.resources.base import Resource from fastmcp.server.middleware.caching import ( CachableToolResult, CallToolSettings, ResponseCachingMiddleware, ResponseCachingStatistics, + _make_call_tool_cache_key, + _make_get_prompt_cache_key, + _make_read_resource_cache_key, ) from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult TEST_URI = AnyUrl("https://test_uri") @@ -631,3 +634,40 @@ class TestCachingWithImportedServerPrefixes: result = await client.call_tool("child_add", {"a": 5, "b": 3}) assert not result.is_error assert tracking_calculator.add_calls == 1 + + +class TestCacheKeyGeneration: + def test_call_tool_key_is_hashed_and_does_not_include_raw_input(self): + msg = mcp.types.CallToolRequestParams( + name="toolX", + arguments={"password": "secret", "path": "../../etc/passwd"}, + ) + + key = _make_call_tool_cache_key(msg) + + assert len(key) == 64 + assert "secret" not in key + assert "../../etc/passwd" not in key + + def test_read_resource_key_is_hashed_and_does_not_include_raw_uri(self): + msg = mcp.types.ReadResourceRequestParams( + uri=AnyUrl("file:///tmp/../../etc/shadow?token=abcd") + ) + + key = _make_read_resource_cache_key(msg) + + assert len(key) == 64 + assert "shadow" not in key + assert "token=abcd" not in key + + def test_get_prompt_key_is_hashed_and_stable(self): + msg = mcp.types.GetPromptRequestParams( + name="promptY", + arguments={"api_key": "ABC123", "scope": "admin"}, + ) + + key = _make_get_prompt_cache_key(msg) + + assert len(key) == 64 + assert "ABC123" not in key + assert key == _make_get_prompt_cache_key(msg) diff --git a/tests/server/middleware/test_initialization_middleware.py b/tests/server/middleware/test_initialization_middleware.py index 7441511eb..1552edcf6 100644 --- a/tests/server/middleware/test_initialization_middleware.py +++ b/tests/server/middleware/test_initialization_middleware.py @@ -10,7 +10,7 @@ from mcp.types import ErrorData, TextContent from fastmcp import Client, FastMCP from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool class InitializationMiddleware(Middleware): diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index 92758dfa4..94c1d565b 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -60,7 +60,7 @@ def mock_duration_ms() -> Generator[float, None]: "fastmcp.server.middleware.logging._get_duration_ms", return_value=0.02 ) patched.start() - yield + yield # ty:ignore[invalid-yield] patched.stop() diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py index 6b822e228..7f3ff9faa 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -8,7 +8,7 @@ import pytest from fastmcp import Client, FastMCP from fastmcp.server.context import Context from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.base import ToolResult @dataclass @@ -479,7 +479,7 @@ class TestApplyMiddlewareParameter: result = await server.call_tool("add", {"a": 1, "b": 2}) - assert result.structured_content["result"] == 3 # type: ignore[union-attr,index] + assert result.structured_content["result"] == 3 # type: ignore[union-attr,index] # ty:ignore[not-subscriptable] assert recording.assert_called(hook="on_call_tool", times=1) async def test_call_tool_with_run_middleware_false(self): @@ -495,7 +495,7 @@ class TestApplyMiddlewareParameter: result = await server.call_tool("add", {"a": 1, "b": 2}, run_middleware=False) - assert result.structured_content["result"] == 3 # type: ignore[union-attr,index] + assert result.structured_content["result"] == 3 # type: ignore[union-attr,index] # ty:ignore[not-subscriptable] # Middleware should not have been called assert len(recording.calls) == 0 @@ -612,10 +612,10 @@ class TestApplyMiddlewareParameter: # With middleware: a=5 becomes a=10, result = 10 + 3 = 13 result_with = await server.call_tool("add", {"a": 5, "b": 3}) - assert result_with.structured_content["result"] == 13 # type: ignore[union-attr,index] + assert result_with.structured_content["result"] == 13 # type: ignore[union-attr,index] # ty:ignore[not-subscriptable] # Without middleware: a=5 stays a=5, result = 5 + 3 = 8 result_without = await server.call_tool( "add", {"a": 5, "b": 3}, run_middleware=False ) - assert result_without.structured_content["result"] == 8 # type: ignore[union-attr,index] + assert result_without.structured_content["result"] == 8 # type: ignore[union-attr,index] # ty:ignore[not-subscriptable] diff --git a/tests/server/middleware/test_middleware_nested.py b/tests/server/middleware/test_middleware_nested.py index e57b9a311..ca73e62a3 100644 --- a/tests/server/middleware/test_middleware_nested.py +++ b/tests/server/middleware/test_middleware_nested.py @@ -9,7 +9,7 @@ from fastmcp import Client, FastMCP from fastmcp.exceptions import ToolError from fastmcp.server.context import Context from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.base import ToolResult @dataclass diff --git a/tests/server/middleware/test_rate_limiting.py b/tests/server/middleware/test_rate_limiting.py index 23132324f..f7d23f7af 100644 --- a/tests/server/middleware/test_rate_limiting.py +++ b/tests/server/middleware/test_rate_limiting.py @@ -421,22 +421,20 @@ class TestRateLimitingMiddlewareIntegration: async def test_global_rate_limiting(self, rate_limit_server): """Test global rate limiting across all clients.""" - rate_limit_server.add_middleware( - RateLimitingMiddleware( - max_requests_per_second=6.0, - burst_capacity=5, # 1 init + 2 list_tools + 2 calls before limit - global_limit=True, # Accounting for initialization and list_tools calls - ) + middleware = RateLimitingMiddleware( + max_requests_per_second=0.001, + burst_capacity=1000, + global_limit=True, ) + rate_limit_server.add_middleware(middleware) async with Client(rate_limit_server) as client: - # Use up the global capacity await client.call_tool("quick_action", {"message": "1"}) - await client.call_tool("quick_action", {"message": "2"}) - # Should be globally rate limited + middleware.global_limiter.tokens = 0 + with pytest.raises(ToolError, match="Global rate limit exceeded"): - await client.call_tool("quick_action", {"message": "3"}) + await client.call_tool("quick_action", {"message": "blocked"}) async def test_rate_limiting_recovery_over_time(self, rate_limit_server): """Test that rate limiting allows requests again after time passes.""" diff --git a/tests/server/middleware/test_response_limiting.py b/tests/server/middleware/test_response_limiting.py index 4e89e05de..b2c6cd810 100644 --- a/tests/server/middleware/test_response_limiting.py +++ b/tests/server/middleware/test_response_limiting.py @@ -5,7 +5,7 @@ from mcp.types import ImageContent, TextContent from fastmcp import Client, FastMCP from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.base import ToolResult class TestResponseLimitingMiddleware: diff --git a/tests/server/middleware/test_tool_injection.py b/tests/server/middleware/test_tool_injection.py index 5a583f543..7509ba4d3 100644 --- a/tests/server/middleware/test_tool_injection.py +++ b/tests/server/middleware/test_tool_injection.py @@ -4,7 +4,6 @@ import math import pytest from inline_snapshot import snapshot -from mcp.types import TextContent from mcp.types import Tool as SDKTool from fastmcp import FastMCP @@ -12,12 +11,10 @@ from fastmcp.client import Client from fastmcp.client.client import CallToolResult from fastmcp.client.transports import FastMCPTransport from fastmcp.server.middleware.tool_injection import ( - PromptToolMiddleware, - ResourceToolMiddleware, ToolInjectionMiddleware, ) +from fastmcp.tools.base import Tool from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool import Tool def multiply_fn(a: int, b: int) -> int: @@ -277,233 +274,3 @@ class TestToolInjectionMiddleware: assert result.structured_content is not None assert isinstance(result.structured_content, dict) assert result.structured_content["result"] == 7 - - -class TestPromptToolMiddleware: - """Tests for PromptToolMiddleware.""" - - @pytest.fixture - def server_with_prompts(self): - """Create a FastMCP server with prompts.""" - mcp = FastMCP("PromptServer") - - @mcp.tool - def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - - @mcp.prompt - def greeting(name: str) -> str: - """Generate a greeting message.""" - return f"Hello, {name}!" - - @mcp.prompt - def farewell(name: str) -> str: - """Generate a farewell message.""" - return f"Goodbye, {name}!" - - return mcp - - async def test_prompt_tools_added_to_list(self, server_with_prompts: FastMCP): - """Test that prompt tools are added to the tool list.""" - middleware = PromptToolMiddleware() - server_with_prompts.add_middleware(middleware) - - async with Client[FastMCPTransport](server_with_prompts) as client: - tools: list[SDKTool] = await client.list_tools() - - tool_names: list[str] = [tool.name for tool in tools] - # Should have: add, list_prompts, get_prompt - assert len(tools) == 3 - assert "add" in tool_names - assert "list_prompts" in tool_names - assert "get_prompt" in tool_names - - async def test_list_prompts_tool_works(self, server_with_prompts: FastMCP): - """Test that the list_prompts tool can be called.""" - middleware = PromptToolMiddleware() - server_with_prompts.add_middleware(middleware) - - async with Client[FastMCPTransport](server_with_prompts) as client: - result: CallToolResult = await client.call_tool( - name="list_prompts", arguments={} - ) - - assert result.content == snapshot( - [ - TextContent( - type="text", - text='[{"name":"greeting","title":null,"description":"Generate a greeting message.","arguments":[{"name":"name","description":null,"required":true}],"icons":null,"_meta":{"fastmcp":{"tags":[]}}},{"name":"farewell","title":null,"description":"Generate a farewell message.","arguments":[{"name":"name","description":null,"required":true}],"icons":null,"_meta":{"fastmcp":{"tags":[]}}}]', - ) - ] - ) - assert result.structured_content is not None - assert result.structured_content["result"] == snapshot( - [ - { - "name": "greeting", - "title": None, - "description": "Generate a greeting message.", - "arguments": [ - {"name": "name", "description": None, "required": True} - ], - "icons": None, - "_meta": {"fastmcp": {"tags": []}}, - }, - { - "name": "farewell", - "title": None, - "description": "Generate a farewell message.", - "arguments": [ - {"name": "name", "description": None, "required": True} - ], - "icons": None, - "_meta": {"fastmcp": {"tags": []}}, - }, - ] - ) - - async def test_get_prompt_tool_works(self, server_with_prompts: FastMCP): - """Test that the get_prompt tool can be called.""" - middleware = PromptToolMiddleware() - server_with_prompts.add_middleware(middleware) - - async with Client[FastMCPTransport](server_with_prompts) as client: - result: CallToolResult = await client.call_tool( - name="get_prompt", - arguments={"name": "greeting", "arguments": {"name": "World"}}, - ) - - # The tool returns the prompt result with structured_content - assert result.content == snapshot( - [ - TextContent( - type="text", - text='{"_meta":null,"description":"Generate a greeting message.","messages":[{"role":"user","content":{"type":"text","text":"Hello, World!","annotations":null,"_meta":null}}]}', - ) - ] - ) - assert result.structured_content is not None - assert result.structured_content == snapshot( - { - "_meta": None, - "description": "Generate a greeting message.", - "messages": [ - { - "role": "user", - "content": { - "type": "text", - "text": "Hello, World!", - "annotations": None, - "_meta": None, - }, - } - ], - } - ) - - -class TestResourceToolMiddleware: - """Tests for ResourceToolMiddleware.""" - - @pytest.fixture - def server_with_resources(self): - """Create a FastMCP server with resources.""" - mcp = FastMCP("ResourceServer") - - @mcp.tool - def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - - @mcp.resource("file://config.txt") - def config_resource() -> str: - """Get configuration.""" - return "debug=true" - - @mcp.resource("file://data.json") - def data_resource() -> str: - """Get data.""" - return '{"count": 42}' - - return mcp - - async def test_resource_tools_added_to_list(self, server_with_resources: FastMCP): - """Test that resource tools are added to the tool list.""" - middleware = ResourceToolMiddleware() - server_with_resources.add_middleware(middleware) - - async with Client[FastMCPTransport](server_with_resources) as client: - tools: list[SDKTool] = await client.list_tools() - - tool_names: list[str] = [tool.name for tool in tools] - # Should have: add, list_resources, read_resource - assert len(tools) == 3 - assert "add" in tool_names - assert "list_resources" in tool_names - assert "read_resource" in tool_names - - async def test_list_resources_tool_works(self, server_with_resources: FastMCP): - """Test that the list_resources tool can be called.""" - middleware = ResourceToolMiddleware() - server_with_resources.add_middleware(middleware) - - async with Client[FastMCPTransport](server_with_resources) as client: - result: CallToolResult = await client.call_tool( - name="list_resources", arguments={} - ) - - assert result.structured_content is not None - assert result.structured_content["result"] == snapshot( - [ - { - "name": "config_resource", - "title": None, - "uri": "file://config.txt/", - "description": "Get configuration.", - "mimeType": "text/plain", - "size": None, - "icons": None, - "annotations": None, - "_meta": {"fastmcp": {"tags": []}}, - }, - { - "name": "data_resource", - "title": None, - "uri": "file://data.json/", - "description": "Get data.", - "mimeType": "text/plain", - "size": None, - "icons": None, - "annotations": None, - "_meta": {"fastmcp": {"tags": []}}, - }, - ] - ) - - async def test_read_resource_tool_works(self, server_with_resources: FastMCP): - """Test that the read_resource tool can be called.""" - middleware = ResourceToolMiddleware() - server_with_resources.add_middleware(middleware) - - async with Client[FastMCPTransport](server_with_resources) as client: - result: CallToolResult = await client.call_tool( - name="read_resource", arguments={"uri": "file://config.txt"} - ) - - assert result.content == snapshot( - [ - TextContent( - type="text", - text='{"contents":[{"content":"debug=true","mime_type":"text/plain","meta":null}],"meta":null}', - ) - ] - ) - assert result.structured_content == snapshot( - { - "contents": [ - {"content": "debug=true", "mime_type": "text/plain", "meta": None} - ], - "meta": None, - } - ) diff --git a/tests/server/mount/test_advanced.py b/tests/server/mount/test_advanced.py index 3835ceca9..f9697b527 100644 --- a/tests/server/mount/test_advanced.py +++ b/tests/server/mount/test_advanced.py @@ -2,6 +2,7 @@ import pytest from mcp.types import TextContent +from starlette.routing import Route from fastmcp import FastMCP from fastmcp.client import Client @@ -82,7 +83,7 @@ class TestCustomRouteForwarding: routes = server._get_additional_http_routes() assert len(routes) == 1 - assert hasattr(routes[0], "path") + assert isinstance(routes[0], Route) assert routes[0].path == "/test" async def test_mounted_servers_tracking(self): @@ -145,10 +146,102 @@ class TestCustomRouteForwarding: routes = server._get_additional_http_routes() assert len(routes) == 2 - route_paths = [route.path for route in routes if hasattr(route, "path")] + route_paths = [route.path for route in routes if isinstance(route, Route)] assert "/route1" in route_paths assert "/route2" in route_paths + async def test_mounted_server_custom_routes_forwarded(self): + """Test that custom routes from a mounted server appear in the parent. + + Regression test for https://github.com/PrefectHQ/fastmcp/issues/3457 + where custom_route endpoints defined on a child server were silently + dropped when the child was mounted onto a parent, resulting in 404s. + """ + parent = FastMCP("Parent") + child = FastMCP("Child") + + @child.custom_route("/readyz", methods=["GET"]) + async def readiness_check(request): + from starlette.responses import JSONResponse + + return JSONResponse({"status": "ok"}) + + parent.mount(child) + + routes = parent._get_additional_http_routes() + assert len(routes) == 1 + assert isinstance(routes[0], Route) + assert routes[0].path == "/readyz" + + async def test_mounted_server_custom_routes_with_namespace(self): + """Test that custom routes from a namespaced mount are forwarded.""" + parent = FastMCP("Parent") + child = FastMCP("Child") + + @child.custom_route("/health", methods=["GET"]) + async def health(request): + from starlette.responses import JSONResponse + + return JSONResponse({"status": "ok"}) + + parent.mount(child, namespace="child") + + routes = parent._get_additional_http_routes() + assert len(routes) == 1 + assert isinstance(routes[0], Route) + assert routes[0].path == "/health" + + async def test_deeply_nested_custom_routes_forwarded(self): + """Test that custom routes from deeply nested mounts are collected.""" + root = FastMCP("Root") + middle = FastMCP("Middle") + leaf = FastMCP("Leaf") + + @leaf.custom_route("/leaf-health", methods=["GET"]) + async def leaf_health(request): + from starlette.responses import JSONResponse + + return JSONResponse({"status": "ok"}) + + @middle.custom_route("/middle-health", methods=["GET"]) + async def middle_health(request): + from starlette.responses import JSONResponse + + return JSONResponse({"status": "ok"}) + + middle.mount(leaf) + root.mount(middle) + + routes = root._get_additional_http_routes() + route_paths = [r.path for r in routes if isinstance(r, Route)] + assert "/leaf-health" in route_paths + assert "/middle-health" in route_paths + assert len(route_paths) == 2 + + async def test_mounted_custom_routes_http_app_integration(self): + """End-to-end: custom routes from mounted servers are reachable via http_app. + + This reproduces the exact scenario from issue #3457. + """ + from starlette.testclient import TestClient + + parent = FastMCP("Parent") + child = FastMCP("Child") + + @child.custom_route("/readyz", methods=["GET"]) + async def readiness_check(request): + from starlette.responses import JSONResponse + + return JSONResponse({"status": "ok"}) + + parent.mount(child) + + app = parent.http_app() + client = TestClient(app) + response = client.get("/readyz") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + class TestDeeplyNestedMount: """Test deeply nested mount scenarios (3+ levels deep). diff --git a/tests/server/mount/test_mount.py b/tests/server/mount/test_mount.py index b1b8ee0c0..1850a3d28 100644 --- a/tests/server/mount/test_mount.py +++ b/tests/server/mount/test_mount.py @@ -9,7 +9,7 @@ from mcp.types import TextContent from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import SSETransport -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.tools.tool_transform import TransformedTool diff --git a/tests/server/providers/local_provider_tools/test_context.py b/tests/server/providers/local_provider_tools/test_context.py index 935a9d8af..2bd84c3a3 100644 --- a/tests/server/providers/local_provider_tools/test_context.py +++ b/tests/server/providers/local_provider_tools/test_context.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from typing_extensions import TypedDict from fastmcp import Context, FastMCP -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool def _normalize_anyof_order(schema): diff --git a/tests/server/providers/local_provider_tools/test_decorator.py b/tests/server/providers/local_provider_tools/test_decorator.py index 92c5df44e..3bf490d4f 100644 --- a/tests/server/providers/local_provider_tools/test_decorator.py +++ b/tests/server/providers/local_provider_tools/test_decorator.py @@ -9,7 +9,7 @@ from typing_extensions import TypedDict from fastmcp import FastMCP from fastmcp.exceptions import NotFoundError -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool def _normalize_anyof_order(schema): diff --git a/tests/server/providers/local_provider_tools/test_output_schema.py b/tests/server/providers/local_provider_tools/test_output_schema.py index 6f1f38d44..31ae3e73c 100644 --- a/tests/server/providers/local_provider_tools/test_output_schema.py +++ b/tests/server/providers/local_provider_tools/test_output_schema.py @@ -1,17 +1,18 @@ """Tests for tool output schemas.""" from dataclasses import dataclass -from typing import Any +from typing import Any, Literal import pytest from mcp.types import ( TextContent, ) from pydantic import AnyUrl, BaseModel, TypeAdapter -from typing_extensions import TypedDict +from typing_extensions import TypeAliasType, TypedDict from fastmcp import FastMCP -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.base import ToolResult +from fastmcp.tools.function_parsing import _is_object_schema from fastmcp.utilities.json_schema import compress_schema @@ -267,6 +268,30 @@ class TestToolOutputSchema: assert isinstance(result.content[2], TextContent) assert result.content[2].text == "direct MCP content" + async def test_wrapped_result_includes_meta_flag(self): + """Wrapped results include wrap_result in meta.""" + server = FastMCP() + + @server.tool + def list_tool() -> list[dict]: + return [{"a": 1}] + + result = await server.call_tool("list_tool", {}) + assert result.structured_content == {"result": [{"a": 1}]} + assert result.meta == {"fastmcp": {"wrap_result": True}} + + async def test_unwrapped_result_has_no_meta_flag(self): + """Unwrapped dict results do not include wrap_result in meta.""" + server = FastMCP() + + @server.tool + def dict_tool() -> dict[str, int]: + return {"value": 42} + + result = await server.call_tool("dict_tool", {}) + assert result.structured_content == {"value": 42} + assert result.meta is None + async def test_output_schema_serialization_edge_cases(self): """Test edge cases in output schema serialization.""" mcp = FastMCP() @@ -282,3 +307,99 @@ class TestToolOutputSchema: result = await mcp.call_tool("edge_case_tool", {}) assert result.structured_content == {"result": [42, "hello"]} + + async def test_output_schema_wraps_non_object_ref_schema(self): + """Root $ref schemas should only skip wrapping when they resolve to objects.""" + mcp = FastMCP() + AliasType = TypeAliasType("AliasType", Literal["foo", "bar"]) + + @mcp.tool + def alias_tool() -> AliasType: + return "foo" + + tools = await mcp.list_tools() + tool = next(t for t in tools if t.name == "alias_tool") + + expected_inner_schema = compress_schema( + TypeAdapter(AliasType).json_schema(mode="serialization"), + prune_titles=True, + ) + assert tool.output_schema == { + "type": "object", + "properties": {"result": expected_inner_schema}, + "required": ["result"], + "x-fastmcp-wrap-result": True, + } + + result = await mcp.call_tool("alias_tool", {}) + assert result.structured_content == {"result": "foo"} + + +class TestIsObjectSchemaRefResolution: + """Tests for $ref resolution in _is_object_schema, including JSON Pointer + escaping and nested $defs paths.""" + + def test_simple_ref_to_object(self): + schema = { + "$ref": "#/$defs/MyModel", + "$defs": { + "MyModel": {"type": "object", "properties": {"x": {"type": "int"}}} + }, + } + assert _is_object_schema(schema) is True + + def test_simple_ref_to_non_object(self): + schema = { + "$ref": "#/$defs/MyEnum", + "$defs": {"MyEnum": {"enum": ["a", "b"]}}, + } + assert _is_object_schema(schema) is False + + def test_nested_defs_path(self): + """Refs like #/$defs/Outer/$defs/Inner should walk into nested dicts.""" + schema = { + "$ref": "#/$defs/Outer/$defs/Inner", + "$defs": { + "Outer": { + "$defs": { + "Inner": { + "type": "object", + "properties": {"y": {"type": "string"}}, + }, + }, + }, + }, + } + assert _is_object_schema(schema) is True + + def test_nested_defs_non_object(self): + schema = { + "$ref": "#/$defs/Outer/$defs/Inner", + "$defs": { + "Outer": { + "$defs": { + "Inner": {"type": "string"}, + }, + }, + }, + } + assert _is_object_schema(schema) is False + + def test_json_pointer_tilde_escape(self): + """~0 should unescape to ~ and ~1 should unescape to /.""" + schema = { + "$ref": "#/$defs/has~1slash~0tilde", + "$defs": {"has/slash~tilde": {"type": "object", "properties": {}}}, + } + assert _is_object_schema(schema) is True + + def test_missing_nested_segment_returns_false(self): + schema = { + "$ref": "#/$defs/Outer/$defs/Missing", + "$defs": { + "Outer": { + "$defs": {}, + }, + }, + } + assert _is_object_schema(schema) is False diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/providers/openapi/test_openapi_features.py index ef4aeb002..ac78f1b10 100644 --- a/tests/server/providers/openapi/test_openapi_features.py +++ b/tests/server/providers/openapi/test_openapi_features.py @@ -9,7 +9,10 @@ from httpx import Response from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.providers.openapi import OpenAPIProvider -from fastmcp.server.providers.openapi.components import _extract_mime_type_from_route +from fastmcp.server.providers.openapi.components import ( + _extract_mime_type_from_route, + _redact_headers, +) from fastmcp.server.providers.openapi.routing import MCPType, RouteMap from fastmcp.utilities.openapi.models import HTTPRoute, ResponseInfo @@ -982,3 +985,45 @@ class TestValidateOutput: assert get_user.outputSchema.get("additionalProperties") is True # Should NOT have specific properties from the original schema assert "properties" not in get_user.outputSchema + + +class TestRedactHeaders: + """Test that non-safe headers are redacted in debug logging.""" + + def test_known_sensitive_headers_are_redacted(self): + headers = httpx.Headers( + { + "Authorization": "Bearer secret-token", + "X-API-Key": "my-api-key", + "Cookie": "session=abc123", + "Proxy-Authorization": "Basic creds", + "Content-Type": "application/json", + "Accept": "text/html", + } + ) + redacted = _redact_headers(headers) + assert redacted["authorization"] == "***" + assert redacted["x-api-key"] == "***" + assert redacted["cookie"] == "***" + assert redacted["proxy-authorization"] == "***" + assert redacted["content-type"] == "application/json" + assert redacted["accept"] == "text/html" + + def test_arbitrary_auth_headers_are_redacted(self): + """Arbitrary header names (e.g. OpenAPI apiKey-in-header) are redacted.""" + headers = httpx.Headers( + { + "X-Custom-Token": "secret", + "X-My-Service-Key": "also-secret", + "Content-Type": "application/json", + } + ) + redacted = _redact_headers(headers) + assert redacted["x-custom-token"] == "***" + assert redacted["x-my-service-key"] == "***" + assert redacted["content-type"] == "application/json" + + def test_safe_only_headers(self): + headers = httpx.Headers({"Content-Type": "application/json"}) + redacted = _redact_headers(headers) + assert redacted == {"content-type": "application/json"} diff --git a/tests/server/providers/proxy/test_proxy_client.py b/tests/server/providers/proxy/test_proxy_client.py index 7b3bb19b9..52db25cc6 100644 --- a/tests/server/providers/proxy/test_proxy_client.py +++ b/tests/server/providers/proxy/test_proxy_client.py @@ -17,7 +17,7 @@ from fastmcp.client.logging import LogMessage from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams from fastmcp.exceptions import ToolError from fastmcp.server.elicitation import AcceptedElicitation -from fastmcp.server.providers.proxy import ProxyClient +from fastmcp.server.providers.proxy import ProxyClient, _create_client_factory @pytest.fixture @@ -419,12 +419,20 @@ class TestProxyClient: assert hasattr(proxy_via_as_proxy, "_local_provider") assert hasattr(proxy_via_factory, "_local_provider") - async def test_connected_client_reuses_sessions(self, fastmcp_server: FastMCP): - """Test that connected clients passed to as_proxy reuse sessions (preserves #959 behavior).""" - # Create a connected client (should reuse sessions) - async with Client(fastmcp_server) as connected_client: - proxy = FastMCP.as_proxy(connected_client) + async def test_connected_proxy_client_uses_fresh_sessions( + self, fastmcp_server: FastMCP + ): + """Connected ProxyClient targets should create fresh sessions to avoid stale context.""" + async with ProxyClient(fastmcp_server) as connected_client: + factory = _create_client_factory(connected_client) - # Verify the proxy is created successfully and uses session reuse - assert proxy is not None - assert hasattr(proxy, "_local_provider") + client_a = factory() + client_b = factory() + + assert isinstance(client_a, Client) + assert isinstance(client_b, Client) + assert client_a is not connected_client + assert client_b is not connected_client + assert client_a is not client_b + assert not client_a.is_connected() + assert not client_b.is_connected() diff --git a/tests/server/providers/proxy/test_proxy_server.py b/tests/server/providers/proxy/test_proxy_server.py index 675fad35e..827607028 100644 --- a/tests/server/providers/proxy/test_proxy_server.py +++ b/tests/server/providers/proxy/test_proxy_server.py @@ -1,6 +1,8 @@ import inspect import json +import time from typing import Any, cast +from unittest.mock import AsyncMock, patch import mcp.types as mcp_types import pytest @@ -19,8 +21,9 @@ from fastmcp.server import create_proxy from fastmcp.server.providers.proxy import ( FastMCPProxy, ProxyClient, + ProxyProvider, ) -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.base import ToolResult from fastmcp.tools.tool_transform import ( ToolTransformConfig, ) @@ -130,6 +133,25 @@ def fastmcp_server(): def welcome(name: str) -> str: return f"Welcome to FastMCP, {name}!" + @server.prompt + def image_prompt(): + """A prompt that returns an image.""" + from fastmcp.prompts.base import Message, PromptResult + + return PromptResult( + messages=[ + Message("Here is an image:"), + Message( + content=mcp_types.ImageContent( + type="image", + data="iVBORw0KGgoAAAANSUhEUg==", + mimeType="image/png", + ), + role="user", + ), + ] + ) + return server @@ -656,6 +678,22 @@ class TestPrompts: param_names = [arg.name for arg in welcome_prompt.arguments or []] assert "extra" in param_names + async def test_proxy_prompt_preserves_image_content( + self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy + ): + """Test that ProxyPrompt preserves ImageContent without lossy conversion.""" + async with Client(fastmcp_server) as client: + result = await client.get_prompt("image_prompt") + 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 + # 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==" + assert proxy_result.messages[1].content.mimeType == "image/png" + async def test_proxy_handles_multiple_concurrent_tasks_correctly( proxy_server: FastMCPProxy, @@ -732,3 +770,118 @@ class TestProxyComponentEnableDisable: with pytest.raises(NotImplementedError, match="server.disable"): prompt.disable() + + +class TestProxyProviderCache: + """Tests for the ProxyProvider component list caching.""" + + async def test_get_tool_uses_cached_list(self, fastmcp_server): + """Calling call_tool should resolve from cache after an initial list.""" + provider = ProxyProvider( + lambda: ProxyClient(FastMCPTransport(fastmcp_server)), + ) + # Warm the cache via list + tools = await provider.list_tools() + assert any(t.name == "greet" for t in tools) + + # _get_tool should resolve from cache without calling _list_tools again + with patch.object( + provider, "_get_client", new_callable=AsyncMock + ) as mock_client: + tool = await provider._get_tool("greet") + assert tool is not None + assert tool.name == "greet" + mock_client.assert_not_called() + + async def test_get_tool_fetches_on_cold_cache(self, fastmcp_server): + """First _get_tool with no prior list should populate the cache.""" + provider = ProxyProvider( + lambda: ProxyClient(FastMCPTransport(fastmcp_server)), + ) + assert provider._tools_cache is None + tool = await provider._get_tool("greet") + assert tool is not None + assert provider._tools_cache is not None + + async def test_cache_expires_after_ttl(self, fastmcp_server): + """After TTL expires, _get_tool should re-fetch from the backend.""" + provider = ProxyProvider( + lambda: ProxyClient(FastMCPTransport(fastmcp_server)), + cache_ttl=0.0, + ) + # Warm the cache + await provider._list_tools() + # With ttl=0 the cache is immediately stale, so _get_tool must re-fetch + assert provider._tools_cache is not None + original_ts = provider._tools_cache.timestamp + + time.sleep(0.05) + + await provider._get_tool("greet") + assert provider._tools_cache.timestamp > original_ts + + async def test_list_tools_refreshes_cache(self, fastmcp_server): + """Explicit list_tools always refreshes the cache timestamp.""" + provider = ProxyProvider( + lambda: ProxyClient(FastMCPTransport(fastmcp_server)), + ) + await provider._list_tools() + first_ts = provider._tools_cache.timestamp # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + # Tiny sleep so monotonic clock advances + time.sleep(0.05) + + await provider._list_tools() + assert provider._tools_cache.timestamp > first_ts # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_cache_ttl_zero_disables_caching(self, fastmcp_server): + """With cache_ttl=0, every _get_tool call should re-fetch.""" + provider = ProxyProvider( + lambda: ProxyClient(FastMCPTransport(fastmcp_server)), + cache_ttl=0.0, + ) + # Each _get_tool call should trigger a fresh _list_tools + call_count = 0 + original_list = provider._list_tools + + async def counting_list(): + nonlocal call_count + call_count += 1 + return await original_list() + + with patch.object(provider, "_list_tools", side_effect=counting_list): + await provider._get_tool("greet") + await provider._get_tool("add") + assert call_count == 2 + + async def test_get_resource_uses_cache(self, fastmcp_server): + """Resource lookups should also use the cache.""" + provider = ProxyProvider( + lambda: ProxyClient(FastMCPTransport(fastmcp_server)), + ) + await provider._list_resources() + with patch.object( + provider, "_get_client", new_callable=AsyncMock + ) as mock_client: + # Even if no resources match, the cache is used (no backend call) + await provider._get_resource("config://app") + mock_client.assert_not_called() + + async def test_call_tool_through_server_uses_cache(self, fastmcp_server): + """End-to-end: calling a tool on a proxy server should only connect + for the actual tool execution, not for tool resolution.""" + proxy = create_proxy(fastmcp_server) + # Warm the cache by listing + await proxy.list_tools() + + # Now call a tool — the provider's _list_tools should NOT be called + # because the cache is warm. The connection happens only in ProxyTool.run. + proxy_provider = next( + p for p in proxy.providers if isinstance(p, ProxyProvider) + ) + with patch.object( + proxy_provider, "_list_tools", wraps=proxy_provider._list_tools + ) as mock_list: + result = await proxy.call_tool("greet", {"name": "Alice"}) + mock_list.assert_not_called() + assert result.content[0].text == "Hello, Alice!" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] diff --git a/tests/server/providers/test_base_provider.py b/tests/server/providers/test_base_provider.py index ed084c34d..0e8898a3f 100644 --- a/tests/server/providers/test_base_provider.py +++ b/tests/server/providers/test_base_provider.py @@ -5,7 +5,7 @@ from typing import Any from fastmcp.server.providers.base import Provider from fastmcp.server.tasks.config import TaskConfig from fastmcp.server.transforms import Namespace -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult class CustomTool(Tool): diff --git a/tests/server/providers/test_fastmcp_provider.py b/tests/server/providers/test_fastmcp_provider.py index f7f86d9a3..4d739858d 100644 --- a/tests/server/providers/test_fastmcp_provider.py +++ b/tests/server/providers/test_fastmcp_provider.py @@ -4,11 +4,11 @@ import mcp.types as mt from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.prompts.prompt import PromptResult -from fastmcp.resources.resource import ResourceResult +from fastmcp.prompts.base import PromptResult +from fastmcp.resources.base import ResourceResult from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.server.providers import FastMCPProvider -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.base import ToolResult class ToolTracingMiddleware(Middleware): diff --git a/tests/server/providers/test_local_provider.py b/tests/server/providers/test_local_provider.py index 2db664338..ebc74f90c 100644 --- a/tests/server/providers/test_local_provider.py +++ b/tests/server/providers/test_local_provider.py @@ -15,10 +15,10 @@ import pytest from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.prompts.prompt import Prompt +from fastmcp.prompts.base import Prompt from fastmcp.server.providers.local_provider import LocalProvider from fastmcp.server.tasks import TaskConfig -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult class TestLocalProviderStorage: diff --git a/tests/server/providers/test_local_provider_prompts.py b/tests/server/providers/test_local_provider_prompts.py index 0176a9b53..8f2776759 100644 --- a/tests/server/providers/test_local_provider_prompts.py +++ b/tests/server/providers/test_local_provider_prompts.py @@ -9,7 +9,7 @@ import pytest from mcp.types import TextContent from fastmcp import Client, Context, FastMCP -from fastmcp.prompts.prompt import Prompt, PromptResult +from fastmcp.prompts.base import Prompt, PromptResult class TestPromptContext: diff --git a/tests/server/providers/test_skills_provider.py b/tests/server/providers/test_skills_provider.py index 40ec0fac7..3caa96cf4 100644 --- a/tests/server/providers/test_skills_provider.py +++ b/tests/server/providers/test_skills_provider.py @@ -169,6 +169,28 @@ This is my skill content. assert "reference.md" in paths assert "scripts/helper.py" in paths + async def test_manifest_ignores_symlink_target_outside_skill(self, tmp_path: Path): + skill_dir = tmp_path / "symlinked-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Skill\n") + + outside_file = tmp_path / "outside.txt" + outside_file.write_text("secret") + (skill_dir / "leak.txt").symlink_to(outside_file) + + mcp = FastMCP("Test") + mcp.add_provider(SkillProvider(skill_path=skill_dir)) + + async with Client(mcp) as client: + result = await client.read_resource( + AnyUrl("skill://symlinked-skill/_manifest") + ) + manifest = json.loads(result[0].text) + + paths = {f["path"] for f in manifest["files"]} + assert "SKILL.md" in paths + assert "leak.txt" not in paths + async def test_read_supporting_file_via_template(self, single_skill_dir: Path): mcp = FastMCP("Test") mcp.add_provider(SkillProvider(skill_path=single_skill_dir)) diff --git a/tests/server/sampling/test_prepare_tools.py b/tests/server/sampling/test_prepare_tools.py index c08a27ae1..b0639b492 100644 --- a/tests/server/sampling/test_prepare_tools.py +++ b/tests/server/sampling/test_prepare_tools.py @@ -103,7 +103,7 @@ class TestPrepareTools: """Test that invalid types raise TypeError.""" with pytest.raises(TypeError, match="Expected SamplingTool, FunctionTool"): - prepare_tools(["not a tool"]) # type: ignore[arg-type] + 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.""" diff --git a/tests/server/sampling/test_sampling_tool.py b/tests/server/sampling/test_sampling_tool.py index a95b393ac..1541e2d92 100644 --- a/tests/server/sampling/test_sampling_tool.py +++ b/tests/server/sampling/test_sampling_tool.py @@ -1,7 +1,12 @@ """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 @@ -224,7 +229,7 @@ class TestSamplingToolFromCallableTool: TypeError, match="Expected FunctionTool or TransformedTool", ): - SamplingTool.from_callable_tool(NotATool()) # type: ignore[arg-type] + 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.""" @@ -233,7 +238,7 @@ class TestSamplingToolFromCallableTool: pass with pytest.raises(TypeError, match="Expected FunctionTool or TransformedTool"): - SamplingTool.from_callable_tool(my_function) # type: ignore[arg-type] + 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.""" @@ -290,3 +295,146 @@ class TestSamplingToolFromCallableTool: 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/tasks/conftest.py b/tests/server/tasks/conftest.py new file mode 100644 index 000000000..496053bfa --- /dev/null +++ b/tests/server/tasks/conftest.py @@ -0,0 +1 @@ +"""Configuration for server task tests.""" diff --git a/tests/server/tasks/test_custom_subclass_tasks.py b/tests/server/tasks/test_custom_subclass_tasks.py index 381fd24ac..2077e066c 100644 --- a/tests/server/tasks/test_custom_subclass_tasks.py +++ b/tests/server/tasks/test_custom_subclass_tasks.py @@ -13,7 +13,7 @@ import pytest from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.tasks import TaskConfig -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.components import FastMCPComponent diff --git a/tests/server/tasks/test_resource_task_meta_parameter.py b/tests/server/tasks/test_resource_task_meta_parameter.py index 50650f9d5..ec5137932 100644 --- a/tests/server/tasks/test_resource_task_meta_parameter.py +++ b/tests/server/tasks/test_resource_task_meta_parameter.py @@ -10,7 +10,7 @@ from mcp.shared.exceptions import McpError from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.resources.resource import Resource +from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.tasks.config import TaskMeta diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/server/tasks/test_server_tasks_parameter.py index 8bb0ec6c8..af3d16641 100644 --- a/tests/server/tasks/test_server_tasks_parameter.py +++ b/tests/server/tasks/test_server_tasks_parameter.py @@ -6,10 +6,13 @@ components (tools, prompts, resources), and that explicit component-level settings properly override the server default. """ +import pytest + from fastmcp import FastMCP from fastmcp.client import Client +@pytest.mark.timeout(10) async def test_server_tasks_true_defaults_all_components(): """Server with tasks=True makes all components default to supporting tasks.""" mcp = FastMCP("test", tasks=True) diff --git a/tests/server/tasks/test_task_config.py b/tests/server/tasks/test_task_config.py index 6287df933..c94ffec6a 100644 --- a/tests/server/tasks/test_task_config.py +++ b/tests/server/tasks/test_task_config.py @@ -16,7 +16,7 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.exceptions import ToolError from fastmcp.server.tasks import TaskConfig -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool class TestTaskConfigNormalization: diff --git a/tests/server/tasks/test_task_dependencies.py b/tests/server/tasks/test_task_dependencies.py index b3e7faa42..7669f150c 100644 --- a/tests/server/tasks/test_task_dependencies.py +++ b/tests/server/tasks/test_task_dependencies.py @@ -67,7 +67,7 @@ async def dependency_server(): return f"Resource via Docket: {docket is not None}" # Expose for test assertions - mcp._injected_values = injected_values # type: ignore[attr-defined] + mcp._injected_values = injected_values # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] return mcp diff --git a/tests/server/tasks/test_task_meta_parameter.py b/tests/server/tasks/test_task_meta_parameter.py index ecf6d303a..954152580 100644 --- a/tests/server/tasks/test_task_meta_parameter.py +++ b/tests/server/tasks/test_task_meta_parameter.py @@ -13,7 +13,7 @@ from fastmcp.client import Client from fastmcp.exceptions import ToolError from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.server.tasks.config import TaskMeta -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult class TestTaskMetaParameter: diff --git a/tests/server/tasks/test_task_methods.py b/tests/server/tasks/test_task_methods.py index 9dd4d7a62..07bef01ce 100644 --- a/tests/server/tasks/test_task_methods.py +++ b/tests/server/tasks/test_task_methods.py @@ -174,6 +174,7 @@ async def test_task_cancellation_workflow(endpoint_server): assert status.status == "cancelled" +@pytest.mark.timeout(10) async def test_task_cancellation_interrupts_running_coroutine(endpoint_server): """Task cancellation actually interrupts the running coroutine. diff --git a/tests/server/tasks/test_task_mount.py b/tests/server/tasks/test_task_mount.py index b601792e2..1b1558579 100644 --- a/tests/server/tasks/test_task_mount.py +++ b/tests/server/tasks/test_task_mount.py @@ -13,12 +13,12 @@ from docket import Docket from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.prompts.prompt import PromptResult -from fastmcp.resources.resource import ResourceResult +from fastmcp.prompts.base import PromptResult +from fastmcp.resources.base import ResourceResult from fastmcp.server.dependencies import CurrentDocket, CurrentFastMCP from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.server.tasks import TaskConfig -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.base import ToolResult @pytest.fixture(autouse=True) @@ -151,6 +151,7 @@ class TestMountedToolTasks: status = await task.status() assert status.status == "completed" + @pytest.mark.timeout(10) async def test_mounted_tool_task_cancellation(self, parent_server): """Can cancel a mounted tool task.""" async with Client(parent_server) as client: @@ -303,7 +304,7 @@ class TestMountedTaskDependencies: received_docket = [] @child.tool(task=True) - async def tool_with_docket(docket: CurrentDocket = CurrentDocket()) -> str: # type: ignore[invalid-type-form] + async def tool_with_docket(docket: CurrentDocket = CurrentDocket()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form] received_docket.append(docket) return f"docket available: {docket is not None}" @@ -324,7 +325,7 @@ class TestMountedTaskDependencies: received_server = [] @child.tool(task=True) - async def tool_with_server(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] + async def tool_with_server(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form] received_server.append(server) return f"server name: {server.name}" @@ -335,10 +336,80 @@ class TestMountedTaskDependencies: task = await client.call_tool("child_tool_with_server", {}, task=True) await task.result() - # The server should be the child server since that's where the tool is defined assert len(received_server) == 1 - # Note: It might be parent or child depending on implementation - assert received_server[0] is not None + assert received_server[0].name == "server-dep-child" + + +class TestMountedTaskServerContext: + """Test that background tasks on mounted servers resolve to the child server (#3571).""" + + async def test_current_fastmcp_resolves_to_child_server(self): + """CurrentFastMCP() inside a mounted background task returns the child server.""" + child = FastMCP("child") + received_server: list[FastMCP] = [] + + @child.tool(task=True) + async def whoami(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form] + received_server.append(server) + return f"server name: {server.name}" + + parent = FastMCP("parent") + parent.mount(child, namespace="child") + + async with Client(parent) as client: + task = await client.call_tool("child_whoami", {}, task=True) + result = await task.result() + + assert len(received_server) == 1 + assert received_server[0].name == "child" + assert "server name: child" in str(result) + + async def test_context_fastmcp_resolves_to_child_server(self): + """ctx.fastmcp inside a mounted background task returns the child server.""" + from fastmcp import Context + + child = FastMCP("child") + received_server: list[FastMCP] = [] + + @child.tool(task=True) + async def whoami_ctx(ctx: Context) -> str: + received_server.append(ctx.fastmcp) + return f"context server: {ctx.fastmcp.name}" + + parent = FastMCP("parent") + parent.mount(child, namespace="child") + + async with Client(parent) as client: + task = await client.call_tool("child_whoami_ctx", {}, task=True) + result = await task.result() + + assert len(received_server) == 1 + assert received_server[0].name == "child" + assert "context server: child" in str(result) + + async def test_nested_mount_resolves_to_innermost_server(self): + """Doubly-nested mounts resolve to the innermost child server.""" + grandchild = FastMCP("grandchild") + received_server: list[FastMCP] = [] + + @grandchild.tool(task=True) + async def deep_whoami(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form] + received_server.append(server) + return f"server name: {server.name}" + + child = FastMCP("child") + child.mount(grandchild, namespace="gc") + + parent = FastMCP("parent") + parent.mount(child, namespace="child") + + async with Client(parent) as client: + task = await client.call_tool("child_gc_deep_whoami", {}, task=True) + result = await task.result() + + assert len(received_server) == 1 + assert received_server[0].name == "grandchild" + assert "server name: grandchild" in str(result) class TestMultipleMounts: @@ -472,6 +543,35 @@ class TestMountedTaskList: assert child_task.task_id in task_ids +class TestMountedTaskMetadata: + """Test task metadata exposure for mounted tools.""" + + async def test_mounted_tool_list_preserves_task_support_metadata(self): + """Mounted tools should preserve execution.taskSupport in tools/list.""" + child = FastMCP("child") + + @child.tool(task=True) + async def foo() -> dict[str, bool]: + return {"ok": True} + + parent = FastMCP("parent") + parent.mount(child) + + child_tools = await child.list_tools() + parent_tools = await parent.list_tools() + + child_tool = next(t for t in child_tools if t.name == "foo") + parent_tool = next(t for t in parent_tools if t.name == "foo") + + child_mcp_tool = child_tool.to_mcp_tool(name=child_tool.name) + parent_mcp_tool = parent_tool.to_mcp_tool(name=parent_tool.name) + + assert child_mcp_tool.execution is not None + assert parent_mcp_tool.execution is not None + assert child_mcp_tool.execution.taskSupport == "optional" + assert parent_mcp_tool.execution.taskSupport == "optional" + + class TestMountedTaskConfigModes: """Test TaskConfig mode enforcement for mounted tools.""" diff --git a/tests/server/tasks/test_task_proxy.py b/tests/server/tasks/test_task_proxy.py index f1b41a3dc..ce20a9a7b 100644 --- a/tests/server/tasks/test_task_proxy.py +++ b/tests/server/tasks/test_task_proxy.py @@ -17,7 +17,7 @@ from mcp.types import TextContent, TextResourceContents from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport -from fastmcp.server.providers.proxy import ProxyClient +from fastmcp.server import create_proxy @pytest.fixture @@ -60,7 +60,7 @@ def backend_server() -> FastMCP: @pytest.fixture def proxy_server(backend_server: FastMCP) -> FastMCP: """Create a proxy server that forwards to the backend.""" - return FastMCP.as_proxy(ProxyClient(transport=FastMCPTransport(backend_server))) + return create_proxy(FastMCPTransport(backend_server)) class TestProxyToolsSyncExecution: diff --git a/tests/server/test_context.py b/tests/server/test_context.py index 921257c89..9af12b055 100644 --- a/tests/server/test_context.py +++ b/tests/server/test_context.py @@ -37,7 +37,7 @@ class TestParseModelPreferences: 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] + _parse_model_preferences(model_preferences=123) # pyright: ignore[reportArgumentType] # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type] class TestSessionId: diff --git a/tests/server/test_pagination.py b/tests/server/test_pagination.py index f90a763c0..f28fabe1b 100644 --- a/tests/server/test_pagination.py +++ b/tests/server/test_pagination.py @@ -420,6 +420,96 @@ class TestPaginationCycleDetection: assert len(tools) == 1 assert tools[0].name == "my_tool" + async def test_tools_raises_on_auto_pagination_limit(self) -> None: + """list_tools should raise RuntimeError after exceeding max_pages.""" + server = FastMCP() + + @server.tool + def my_tool() -> str: + return "ok" + + async with Client(server) as client: + original = client.list_tools_mcp + call_count = 0 + + async def returning_unique_cursor( + *, + cursor: str | None = None, + ) -> mcp.types.ListToolsResult: + nonlocal call_count + result = await original(cursor=cursor) + call_count += 1 + result.nextCursor = f"cursor-{call_count}" + return result + + with ( + patch.object( + client, "list_tools_mcp", side_effect=returning_unique_cursor + ), + pytest.raises(RuntimeError, match="auto-pagination limit"), + ): + await client.list_tools(max_pages=5) + + async def test_resources_raises_on_auto_pagination_limit(self) -> None: + """list_resources should raise RuntimeError after exceeding max_pages.""" + server = FastMCP() + + @server.resource("test://r") + def my_resource() -> str: + return "data" + + async with Client(server) as client: + original = client.list_resources_mcp + call_count = 0 + + async def returning_unique_cursor( + *, + cursor: str | None = None, + ) -> mcp.types.ListResourcesResult: + nonlocal call_count + result = await original(cursor=cursor) + call_count += 1 + result.nextCursor = f"cursor-{call_count}" + return result + + with ( + patch.object( + client, "list_resources_mcp", side_effect=returning_unique_cursor + ), + pytest.raises(RuntimeError, match="auto-pagination limit"), + ): + await client.list_resources(max_pages=5) + + async def test_prompts_raises_on_auto_pagination_limit(self) -> None: + """list_prompts should raise RuntimeError after exceeding max_pages.""" + server = FastMCP() + + @server.prompt + def my_prompt() -> str: + return "text" + + async with Client(server) as client: + original = client.list_prompts_mcp + call_count = 0 + + async def returning_unique_cursor( + *, + cursor: str | None = None, + ) -> mcp.types.ListPromptsResult: + nonlocal call_count + result = await original(cursor=cursor) + call_count += 1 + result.nextCursor = f"cursor-{call_count}" + return result + + with ( + patch.object( + client, "list_prompts_mcp", side_effect=returning_unique_cursor + ), + pytest.raises(RuntimeError, match="auto-pagination limit"), + ): + await client.list_prompts(max_pages=5) + async def test_normal_pagination_unaffected(self) -> None: """Cycle detection should not interfere with normal pagination.""" server = FastMCP(list_page_size=10) diff --git a/tests/server/test_providers.py b/tests/server/test_providers.py index aa0ea267a..b62df314a 100644 --- a/tests/server/test_providers.py +++ b/tests/server/test_providers.py @@ -7,13 +7,13 @@ import pytest from mcp.types import AnyUrl, TextContent from fastmcp import FastMCP +from fastmcp.prompts.base import Prompt from fastmcp.prompts.function_prompt import FunctionPrompt -from fastmcp.prompts.prompt import Prompt +from fastmcp.resources.base import Resource from fastmcp.resources.function_resource import FunctionResource -from fastmcp.resources.resource import Resource from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate from fastmcp.server.providers import Provider -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.versions import VersionSpec @@ -42,9 +42,9 @@ class SimpleTool(Tool): class SimpleToolProvider(Provider): """A simple provider that returns a configurable list of tools.""" - def __init__(self, tools: list[Tool] | None = None): + def __init__(self, tools: Sequence[Tool] | None = None): super().__init__() - self._tools = tools or [] + self._tools = list(tools) if tools else [] self.list_tools_call_count = 0 self.get_tool_call_count = 0 @@ -68,9 +68,9 @@ class SimpleToolProvider(Provider): class ListOnlyProvider(Provider): """A provider that only implements list_tools (uses default get_tool).""" - def __init__(self, tools: list[Tool]): + def __init__(self, tools: Sequence[Tool]): super().__init__() - self._tools = tools + self._tools = list(tools) self.list_tools_call_count = 0 async def _list_tools(self) -> list[Tool]: diff --git a/tests/server/test_server.py b/tests/server/test_server.py index e3a7f5125..3d62e7092 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -10,7 +10,7 @@ from mcp.types import TextContent, TextResourceContents from fastmcp import Client, FastMCP from fastmcp.server.providers import LocalProvider from fastmcp.tools import FunctionTool -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.tests import temporary_settings diff --git a/tests/server/test_server_lifespan.py b/tests/server/test_server_lifespan.py index ef52e4e22..0a5c6f24c 100644 --- a/tests/server/test_server_lifespan.py +++ b/tests/server/test_server_lifespan.py @@ -1,14 +1,17 @@ """Tests for server_lifespan and session_lifespan behavior.""" +import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import Any +import anyio import pytest from fastmcp import Client, FastMCP from fastmcp.server.context import Context from fastmcp.server.lifespan import ContextManagerLifespan, lifespan +from fastmcp.server.providers import Provider from fastmcp.utilities.lifespan import combine_lifespans @@ -54,6 +57,44 @@ class TestServerLifespan: # when the client session closes assert lifespan_events == ["enter", "exit"] + async def test_server_lifespan_overlapping_sessions(self): + """Test that overlapping sessions keep lifespan active until all sessions close.""" + lifespan_events: list[str] = [] + + resource_state = "missing" + + @asynccontextmanager + async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]: + nonlocal resource_state + lifespan_events.append("enter") + resource_state = "open" + try: + yield {"initialized": True} + finally: + resource_state = "closed" + lifespan_events.append("exit") + + mcp = FastMCP("TestServer", lifespan=server_lifespan) + + @mcp.tool + def get_resource_state() -> str: + return resource_state + + async with Client(mcp) as client1: + result1 = await client1.call_tool("get_resource_state", {}) + assert result1.data == "open" + + async with Client(mcp) as client2: + result2 = await client2.call_tool("get_resource_state", {}) + assert result2.data == "open" + + # client2 exited while client1 is still active; lifespan should remain open + result3 = await client1.call_tool("get_resource_state", {}) + assert result3.data == "open" + assert lifespan_events == ["enter"] + + assert lifespan_events == ["enter", "exit"] + async def test_server_lifespan_context_available(self): """Test that server_lifespan context is available to tools.""" @@ -294,7 +335,7 @@ class TestComposableLifespans: # Composing with non-Lifespan should raise TypeError with helpful message with pytest.raises(TypeError) as exc_info: - my_lifespan | regular_lifespan # type: ignore[operator] + my_lifespan | regular_lifespan # type: ignore[operator] # ty:ignore[unsupported-operator] assert "ContextManagerLifespan" in str(exc_info.value) @@ -505,3 +546,117 @@ class TestCombineLifespans: "dict_exit", "mapping_exit", ] + + +class TestLifespanTeardownShielding: + """Test that async operations in lifespan teardown complete under cancellation. + + When a server shuts down (e.g. Ctrl-C), the cancel scope becomes active. + Lifespan teardown must be shielded from cancellation so that async cleanup + (closing DB connections, flushing buffers, etc.) can actually run. + """ + + async def test_server_lifespan_async_teardown_under_cancellation(self): + """Async operations in server lifespan finally block complete even when cancelled.""" + events: list[str] = [] + + @asynccontextmanager + async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]: + events.append("setup") + try: + yield {} + finally: + events.append("teardown_start") + await asyncio.sleep(0) + events.append("teardown_complete") + + mcp = FastMCP("TestServer", lifespan=server_lifespan) + + async with anyio.create_task_group() as tg: + + async def run_and_cancel() -> None: + async with mcp._lifespan_manager(): + tg.cancel_scope.cancel() + + tg.start_soon(run_and_cancel) + + assert "setup" in events + assert "teardown_start" in events + assert "teardown_complete" in events + + async def test_provider_lifespan_async_teardown_under_cancellation(self): + """Async operations in provider lifespan finally block complete when cancelled.""" + events: list[str] = [] + + class TestProvider(Provider): + @asynccontextmanager + async def lifespan(self) -> AsyncIterator[None]: + events.append("provider_setup") + try: + yield + finally: + events.append("provider_teardown_start") + await asyncio.sleep(0) + events.append("provider_teardown_complete") + + mcp = FastMCP("TestServer", providers=[TestProvider()]) + + async with anyio.create_task_group() as tg: + + async def run_and_cancel() -> None: + async with mcp._lifespan_manager(): + tg.cancel_scope.cancel() + + tg.start_soon(run_and_cancel) + + assert "provider_setup" in events + assert "provider_teardown_start" in events + assert "provider_teardown_complete" in events + + async def test_composed_lifespans_async_teardown_under_cancellation(self): + """Both server and provider async teardown completes under cancellation.""" + events: list[str] = [] + + @asynccontextmanager + async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]: + events.append("server_setup") + try: + yield {} + finally: + events.append("server_teardown_start") + await asyncio.sleep(0) + events.append("server_teardown_complete") + + class TestProvider(Provider): + @asynccontextmanager + async def lifespan(self) -> AsyncIterator[None]: + events.append("provider_setup") + try: + yield + finally: + events.append("provider_teardown_start") + await asyncio.sleep(0) + events.append("provider_teardown_complete") + + mcp = FastMCP( + "TestServer", + lifespan=server_lifespan, + providers=[TestProvider()], + ) + + async with anyio.create_task_group() as tg: + + async def run_and_cancel() -> None: + async with mcp._lifespan_manager(): + tg.cancel_scope.cancel() + + tg.start_soon(run_and_cancel) + + assert events == [ + "server_setup", + "provider_setup", + "provider_teardown_start", + "provider_teardown_complete", + "server_teardown_start", + "server_teardown_complete", + ] diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py index b805f4f9e..8a2a925df 100644 --- a/tests/server/test_tool_annotations.py +++ b/tests/server/test_tool_annotations.py @@ -5,7 +5,7 @@ from mcp.types import Tool as MCPTool from mcp.types import ToolAnnotations, ToolExecution from fastmcp import Client, FastMCP -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool async def test_tool_annotations_in_tool_manager(): diff --git a/tests/server/transforms/test_catalog.py b/tests/server/transforms/test_catalog.py index 01004a2bf..978af1987 100644 --- a/tests/server/transforms/test_catalog.py +++ b/tests/server/transforms/test_catalog.py @@ -12,7 +12,7 @@ from fastmcp.server.context import Context from fastmcp.server.transforms import GetToolNext from fastmcp.server.transforms.catalog import CatalogTransform from fastmcp.server.transforms.version_filter import VersionFilter -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.versions import VersionSpec # --------------------------------------------------------------------------- @@ -53,7 +53,7 @@ class CatalogReader(CatalogTransform): def _make_reader_tool(self) -> Tool: transform = self - async def read_catalog(ctx: Context = None) -> list[str]: # type: ignore[assignment] + async def read_catalog(ctx: Context = None) -> list[str]: # type: ignore[assignment] # ty:ignore[invalid-parameter-default] """Return names of tools visible in the catalog.""" transform.last_catalog = await transform.get_tool_catalog(ctx) return [t.name for t in transform.last_catalog] @@ -81,7 +81,7 @@ class ReplacingTransform(CatalogTransform): def _make_synthetic_tool(self) -> Tool: transform = self - async def count_tools(ctx: Context = None) -> int: # type: ignore[assignment] + async def count_tools(ctx: Context = None) -> int: # type: ignore[assignment] # ty:ignore[invalid-parameter-default] """Return the number of real tools in the catalog.""" catalog = await transform.get_tool_catalog(ctx) return len(catalog) @@ -252,7 +252,7 @@ class TestCatalogAuth: def _extract_result(result: object) -> list[str]: """Extract the list of names from a call_tool ToolResult.""" - for c in result.content: # type: ignore[union-attr] + for c in result.content: # type: ignore[union-attr] # ty:ignore[unresolved-attribute] if isinstance(c, TextContent): return ast.literal_eval(c.text) raise AssertionError("No text content found") diff --git a/tests/server/transforms/test_resources_as_tools.py b/tests/server/transforms/test_resources_as_tools.py index 49297f98a..278d72c25 100644 --- a/tests/server/transforms/test_resources_as_tools.py +++ b/tests/server/transforms/test_resources_as_tools.py @@ -7,6 +7,8 @@ import pytest from fastmcp import FastMCP from fastmcp.client import Client +from fastmcp.exceptions import ToolError +from fastmcp.server.auth import AuthContext from fastmcp.server.transforms import ResourcesAsTools @@ -225,3 +227,132 @@ class TestResourcesAsToolsRepr: mcp = FastMCP("Test") transform = ResourcesAsTools(mcp) assert "ResourcesAsTools" in repr(transform) + + +class TestResourcesAsToolsAnnotations: + """Test ToolAnnotations on generated resource tools.""" + + async def test_list_resources_is_read_only(self): + """list_resources is annotated as read-only by default.""" + mcp = FastMCP("Test") + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "list_resources") + assert tool.annotations is not None + assert tool.annotations.readOnlyHint is True + + async def test_read_resource_is_read_only(self): + """read_resource is annotated as read-only by default.""" + mcp = FastMCP("Test") + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "read_resource") + assert tool.annotations is not None + assert tool.annotations.readOnlyHint is True + + +def _deny_all(ctx: AuthContext) -> bool: + """Auth check that always denies access.""" + return False + + +class TestResourcesAsToolsAuthOnServer: + """Auth checks work when using ResourcesAsTools on a FastMCP server.""" + + async def test_auth_protected_resources_hidden_from_list(self): + """Auth-protected resources are filtered from list_resources tool.""" + mcp = FastMCP("Test") + + @mcp.resource("test://open") + def open_resource() -> str: + return "open content" + + @mcp.resource("test://protected", auth=_deny_all) + def protected_resource() -> str: + return "protected content" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("list_resources", {}) + items = json.loads(result.data) + uris = [r.get("uri") for r in items if r.get("uri")] + assert "test://open" in uris + assert "test://protected" not in uris + + async def test_auth_protected_resource_cannot_be_read(self): + """Auth-protected resources cannot be read via read_resource tool.""" + mcp = FastMCP("Test") + + @mcp.resource("test://protected", auth=_deny_all) + def protected_resource() -> str: + return "protected content" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("read_resource", {"uri": "test://protected"}) + + async def test_open_resource_still_accessible(self): + """Non-auth-protected resources can still be read.""" + mcp = FastMCP("Test") + + @mcp.resource("test://open") + def open_resource() -> str: + return "open content" + + @mcp.resource("test://protected", auth=_deny_all) + def protected_resource() -> str: + return "protected content" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("read_resource", {"uri": "test://open"}) + assert result.data == "open content" + + +class TestResourcesAsToolsVisibilityOnServer: + """Visibility filtering works when using ResourcesAsTools on a server.""" + + async def test_disabled_resources_hidden_from_list(self): + """Disabled resources are not listed via list_resources tool.""" + mcp = FastMCP("Test") + + @mcp.resource("test://public") + def public_resource() -> str: + return "public content" + + @mcp.resource("test://secret") + def secret_resource() -> str: + return "secret content" + + mcp.disable(names={"test://secret"}) + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("list_resources", {}) + items = json.loads(result.data) + uris = [r.get("uri") for r in items if r.get("uri")] + assert "test://public" in uris + assert "test://secret" not in uris + + async def test_disabled_resource_cannot_be_read(self): + """Disabled resources cannot be read via read_resource tool.""" + mcp = FastMCP("Test") + + @mcp.resource("test://secret") + def secret_resource() -> str: + return "secret content" + + mcp.disable(names={"test://secret"}) + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("read_resource", {"uri": "test://secret"}) diff --git a/tests/server/transforms/test_search.py b/tests/server/transforms/test_search.py index 9549f8c01..ee0f5ad40 100644 --- a/tests/server/transforms/test_search.py +++ b/tests/server/transforms/test_search.py @@ -20,7 +20,7 @@ from fastmcp.server.transforms.search.bm25 import ( _catalog_hash, ) from fastmcp.server.transforms.search.regex import RegexSearchTransform -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult # --------------------------------------------------------------------------- # Helpers diff --git a/tests/server/transforms/test_visibility.py b/tests/server/transforms/test_visibility.py index a784af1f4..d3434cc4a 100644 --- a/tests/server/transforms/test_visibility.py +++ b/tests/server/transforms/test_visibility.py @@ -3,7 +3,7 @@ import pytest from fastmcp.server.transforms.visibility import Visibility, is_enabled -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.versions import VersionSpec diff --git a/tests/server/versioning/test_versioning.py b/tests/server/versioning/test_versioning.py index cf910bc4a..f2866df06 100644 --- a/tests/server/versioning/test_versioning.py +++ b/tests/server/versioning/test_versioning.py @@ -3,9 +3,13 @@ from __future__ import annotations +from typing import cast + +import pytest from mcp.types import TextContent from fastmcp import FastMCP +from fastmcp.tools import Tool from fastmcp.utilities.versions import ( VersionKey, compare_versions, @@ -256,3 +260,55 @@ class TestComponentVersioning: prompt = await mcp.get_prompt("greet") assert prompt is not None assert prompt.version == "2.0" + + +class TestVersionValidation: + """Tests for version type validation in components and server.""" + + async def test_fastmcp_version_int_coerced(self): + """FastMCP(version=42) should coerce to string '42'.""" + mcp = FastMCP(version=42) + assert mcp._mcp_server.version == "42" + + async def test_fastmcp_version_float_coerced(self): + """FastMCP(version=1.5) should coerce to string.""" + mcp = FastMCP(version=1.5) + assert mcp._mcp_server.version == "1.5" + + async def test_tool_version_list_rejected(self): + """Tool with version=[1, 2] should raise TypeError.""" + with pytest.raises(TypeError, match="Version must be a string"): + Tool( + name="t", + version=cast(str, [1, 2]), + parameters={"type": "object"}, + ) + + async def test_tool_version_dict_rejected(self): + """Tool with version={'major': 1} should raise TypeError.""" + with pytest.raises(TypeError, match="Version must be a string"): + Tool( + name="t", + version=cast(str, {"major": 1}), + parameters={"type": "object"}, + ) + + async def test_fastmcp_version_list_rejected(self): + """FastMCP(version=[1, 2]) should raise TypeError.""" + with pytest.raises(TypeError, match="Version must be a string"): + FastMCP(version=cast(str, [1, 2])) + + async def test_fastmcp_version_dict_rejected(self): + """FastMCP(version={'v': 1}) should raise TypeError.""" + with pytest.raises(TypeError, match="Version must be a string"): + FastMCP(version=cast(str, {"v": 1})) + + async def test_fastmcp_version_true_rejected(self): + """FastMCP(version=True) should raise TypeError, not coerce to 'True'.""" + with pytest.raises(TypeError, match="got bool"): + FastMCP(version=cast(str, True)) + + async def test_fastmcp_version_false_rejected(self): + """FastMCP(version=False) should raise TypeError, not coerce to 'False'.""" + with pytest.raises(TypeError, match="got bool"): + FastMCP(version=cast(str, False)) diff --git a/tests/server/versioning/test_visibility_version_fallback.py b/tests/server/versioning/test_visibility_version_fallback.py new file mode 100644 index 000000000..071c77d6e --- /dev/null +++ b/tests/server/versioning/test_visibility_version_fallback.py @@ -0,0 +1,376 @@ +"""Tests for version fallback when the highest version is disabled via visibility. + +Regression tests for https://github.com/jlowin/fastmcp/issues/3421: +When the latest version of a component is disabled, get_* methods should +fall back to the next-highest enabled version instead of returning None. +""" +# ruff: noqa: F811 # Intentional function redefinition for version testing + +from __future__ import annotations + +from mcp.server.auth.middleware.auth_context import auth_context_var +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.types import TextContent + +from fastmcp import FastMCP +from fastmcp.server.auth import AccessToken, require_scopes +from fastmcp.utilities.versions import VersionSpec + + +def _make_token(scopes: list[str] | None = None) -> AccessToken: + """Create a test access token.""" + return AccessToken( + token="test-token", + client_id="test-client", + scopes=scopes or [], + expires_at=None, + claims={}, + ) + + +def _set_token(token: AccessToken | None): + """Set the access token in the auth context var.""" + if token is None: + return auth_context_var.set(None) + return auth_context_var.set(AuthenticatedUser(token)) + + +class TestToolVersionFallback: + """Test that disabling the latest tool version falls back correctly.""" + + async def test_list_tools_shows_v1_when_v2_disabled(self): + """list_tools should show v1 when v2 is disabled.""" + mcp = FastMCP() + + @mcp.tool(version="1.0") + def calc() -> int: + return 1 + + @mcp.tool(version="2.0") + def calc() -> int: + return 2 + + mcp.disable(version=VersionSpec(eq="2.0")) + + tools = await mcp.list_tools() + assert len(tools) == 1 + assert tools[0].name == "calc" + assert tools[0].version == "1.0" + + async def test_get_tool_returns_v1_when_v2_disabled(self): + """get_tool should return v1 when v2 is disabled (core bug).""" + mcp = FastMCP() + + @mcp.tool(version="1.0") + def calc() -> int: + return 1 + + @mcp.tool(version="2.0") + def calc() -> int: + return 2 + + mcp.disable(version=VersionSpec(eq="2.0")) + + tool = await mcp.get_tool("calc") + assert tool is not None + assert tool.version == "1.0" + + async def test_call_tool_uses_v1_when_v2_disabled(self): + """call_tool should invoke v1 when v2 is disabled.""" + mcp = FastMCP() + + @mcp.tool(version="1.0") + def calc() -> int: + return 1 + + @mcp.tool(version="2.0") + def calc() -> int: + return 2 + + mcp.disable(version=VersionSpec(eq="2.0")) + + result = await mcp.call_tool("calc", {}) + first = result.content[0] + assert isinstance(first, TextContent) + assert first.text == "1" + + async def test_get_tool_explicit_disabled_version_returns_none(self): + """Requesting a specific disabled version should return None.""" + mcp = FastMCP() + + @mcp.tool(version="1.0") + def calc() -> int: + return 1 + + @mcp.tool(version="2.0") + def calc() -> int: + return 2 + + mcp.disable(version=VersionSpec(eq="2.0")) + + tool = await mcp.get_tool("calc", VersionSpec(eq="2.0")) + assert tool is None + + async def test_get_tool_all_versions_disabled_returns_none(self): + """When all versions are disabled, get_tool returns None.""" + mcp = FastMCP() + + @mcp.tool(version="1.0") + def calc() -> int: + return 1 + + @mcp.tool(version="2.0") + def calc() -> int: + return 2 + + mcp.disable(names={"calc"}) + + tool = await mcp.get_tool("calc") + assert tool is None + + async def test_get_tool_middle_version_fallback(self): + """Disabling v3 should fall back to v2, not v1.""" + mcp = FastMCP() + + @mcp.tool(version="1.0") + def calc() -> int: + return 1 + + @mcp.tool(version="2.0") + def calc() -> int: + return 2 + + @mcp.tool(version="3.0") + def calc() -> int: + return 3 + + mcp.disable(version=VersionSpec(eq="3.0")) + + tool = await mcp.get_tool("calc") + assert tool is not None + assert tool.version == "2.0" + + +class TestResourceVersionFallback: + """Test that disabling the latest resource version falls back correctly.""" + + async def test_get_resource_returns_v1_when_v2_disabled(self): + """get_resource should return v1 when v2 is disabled.""" + mcp = FastMCP() + + @mcp.resource("data://info", version="1.0") + def info() -> str: + return "v1" + + @mcp.resource("data://info", version="2.0") + def info() -> str: + return "v2" + + mcp.disable(version=VersionSpec(eq="2.0")) + + resource = await mcp.get_resource("data://info") + assert resource is not None + assert resource.version == "1.0" + + async def test_get_resource_explicit_disabled_version_returns_none(self): + """Requesting a specific disabled resource version should return None.""" + mcp = FastMCP() + + @mcp.resource("data://info", version="1.0") + def info() -> str: + return "v1" + + @mcp.resource("data://info", version="2.0") + def info() -> str: + return "v2" + + mcp.disable(version=VersionSpec(eq="2.0")) + + resource = await mcp.get_resource("data://info", VersionSpec(eq="2.0")) + assert resource is None + + +class TestResourceTemplateVersionFallback: + """Test that disabling the latest template version falls back correctly.""" + + async def test_get_resource_template_returns_v1_when_v2_disabled(self): + """get_resource_template should return v1 when v2 is disabled.""" + mcp = FastMCP() + + @mcp.resource("data://items/{id}", version="1.0") + def item(id: str) -> str: + return f"v1-{id}" + + @mcp.resource("data://items/{id}", version="2.0") + def item(id: str) -> str: + return f"v2-{id}" + + mcp.disable(version=VersionSpec(eq="2.0")) + + template = await mcp.get_resource_template("data://items/{id}") + assert template is not None + assert template.version == "1.0" + + +class TestPromptVersionFallback: + """Test that disabling the latest prompt version falls back correctly.""" + + async def test_get_prompt_returns_v1_when_v2_disabled(self): + """get_prompt should return v1 when v2 is disabled.""" + mcp = FastMCP() + + @mcp.prompt(version="1.0") + def greet() -> str: + return "hello v1" + + @mcp.prompt(version="2.0") + def greet() -> str: + return "hello v2" + + mcp.disable(version=VersionSpec(eq="2.0")) + + prompt = await mcp.get_prompt("greet") + assert prompt is not None + assert prompt.version == "1.0" + + async def test_get_prompt_explicit_disabled_version_returns_none(self): + """Requesting a specific disabled prompt version should return None.""" + mcp = FastMCP() + + @mcp.prompt(version="1.0") + def greet() -> str: + return "hello v1" + + @mcp.prompt(version="2.0") + def greet() -> str: + return "hello v2" + + mcp.disable(version=VersionSpec(eq="2.0")) + + prompt = await mcp.get_prompt("greet", VersionSpec(eq="2.0")) + assert prompt is None + + +class TestFallbackRespectsAuth: + """Fallback to older versions must enforce auth checks. + + When the highest version is disabled and the code falls back to older + versions, those candidates must go through auth filtering. Otherwise + a protected v1 could be exposed to unauthenticated users when a + public v2 is disabled. + """ + + async def test_tool_fallback_respects_auth(self): + """Disabling v2 should not expose auth-protected v1 to unauthorized users.""" + mcp = FastMCP() + + @mcp.tool(version="1.0", auth=require_scopes("admin")) + def calc() -> int: + return 1 + + @mcp.tool(version="2.0") + def calc() -> int: + return 2 + + mcp.disable(version=VersionSpec(eq="2.0")) + + # Without an admin token, v1 should NOT be returned + tool = await mcp.get_tool("calc") + assert tool is None + + async def test_tool_fallback_allows_authorized_user(self): + """Fallback should return auth-protected v1 to authorized users.""" + mcp = FastMCP() + + @mcp.tool(version="1.0", auth=require_scopes("admin")) + def calc() -> int: + return 1 + + @mcp.tool(version="2.0") + def calc() -> int: + return 2 + + mcp.disable(version=VersionSpec(eq="2.0")) + + token = _make_token(scopes=["admin"]) + tok = _set_token(token) + try: + tool = await mcp.get_tool("calc") + assert tool is not None + assert tool.version == "1.0" + finally: + auth_context_var.reset(tok) + + async def test_resource_fallback_respects_auth(self): + """Disabling v2 should not expose auth-protected v1 resource.""" + mcp = FastMCP() + + @mcp.resource("data://info", version="1.0", auth=require_scopes("admin")) + def info() -> str: + return "v1" + + @mcp.resource("data://info", version="2.0") + def info() -> str: + return "v2" + + mcp.disable(version=VersionSpec(eq="2.0")) + + resource = await mcp.get_resource("data://info") + assert resource is None + + async def test_resource_template_fallback_respects_auth(self): + """Disabling v2 should not expose auth-protected v1 template.""" + mcp = FastMCP() + + @mcp.resource("data://items/{id}", version="1.0", auth=require_scopes("admin")) + def item(id: str) -> str: + return f"v1-{id}" + + @mcp.resource("data://items/{id}", version="2.0") + def item(id: str) -> str: + return f"v2-{id}" + + mcp.disable(version=VersionSpec(eq="2.0")) + + template = await mcp.get_resource_template("data://items/{id}") + assert template is None + + async def test_prompt_fallback_respects_auth(self): + """Disabling v2 should not expose auth-protected v1 prompt.""" + mcp = FastMCP() + + @mcp.prompt(version="1.0", auth=require_scopes("admin")) + def greet() -> str: + return "hello v1" + + @mcp.prompt(version="2.0") + def greet() -> str: + return "hello v2" + + mcp.disable(version=VersionSpec(eq="2.0")) + + prompt = await mcp.get_prompt("greet") + assert prompt is None + + async def test_fallback_skips_unauthorized_picks_next(self): + """When multiple fallback candidates exist, skip unauthorized ones.""" + mcp = FastMCP() + + @mcp.tool(version="1.0") + def calc() -> int: + return 1 + + @mcp.tool(version="2.0", auth=require_scopes("admin")) + def calc() -> int: + return 2 + + @mcp.tool(version="3.0") + def calc() -> int: + return 3 + + mcp.disable(version=VersionSpec(eq="3.0")) + + # v2 requires admin, so unauthorized user should get v1 + tool = await mcp.get_tool("calc") + assert tool is not None + assert tool.version == "1.0" diff --git a/tests/test_apps.py b/tests/test_apps.py index 5d41897a2..125772897 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -11,7 +11,7 @@ from typing import Any import pytest from fastmcp import Client, FastMCP -from fastmcp.server.apps import ( +from fastmcp.apps import ( UI_EXTENSION_ID, UI_MIME_TYPE, AppConfig, @@ -225,10 +225,16 @@ class TestToolRegistrationWithApp: def my_tool() -> str: return "hello" + # App-only tools (visibility=["app"]) are hidden from list_tools tools = list(await server.list_tools()) - assert tools[0].meta is not None - assert tools[0].meta["ui"]["resourceUri"] == "ui://foo" - assert tools[0].meta["ui"]["visibility"] == ["app"] + assert len(tools) == 0 + + # But the tool exists on the provider + tool = await server._get_tool("my_tool") + assert tool is not None + assert tool.meta is not None + assert tool.meta["ui"]["resourceUri"] == "ui://foo" + assert tool.meta["ui"]["visibility"] == ["app"] async def test_app_merges_with_existing_meta(self): server = FastMCP("test") @@ -250,8 +256,10 @@ class TestToolRegistrationWithApp: def my_tool() -> str: return "hello" - tools = list(await server.list_tools()) - mcp_tool = tools[0].to_mcp_tool() + # App-only tools are hidden from list_tools, verify via provider + tool = await server._get_tool("my_tool") + assert tool is not None + mcp_tool = tool.to_mcp_tool() assert mcp_tool.meta is not None assert mcp_tool.meta["ui"]["resourceUri"] == "ui://app" assert mcp_tool.meta["ui"]["visibility"] == ["app"] @@ -414,7 +422,9 @@ class TestIntegration: server = FastMCP("test") @server.tool( - app=AppConfig(resource_uri="ui://app/view.html", visibility=["app"]) + app=AppConfig( + resource_uri="ui://app/view.html", visibility=["app", "model"] + ) ) async def my_tool() -> dict[str, str]: return {"result": "ok"} @@ -422,11 +432,10 @@ class TestIntegration: async with Client(server) as client: tools = await client.list_tools() assert len(tools) == 1 - # _meta.ui is preserved — the host decides what to do with it meta = tools[0].meta assert meta is not None assert meta["ui"]["resourceUri"] == "ui://app/view.html" - assert meta["ui"]["visibility"] == ["app"] + assert meta["ui"]["visibility"] == ["app", "model"] async def test_resource_with_ui_scheme_roundtrip(self): server = FastMCP("test") @@ -470,7 +479,9 @@ class TestIntegration: """Server advertises extension AND tool has app meta.""" server = FastMCP("test") - @server.tool(app=AppConfig(resource_uri="ui://dashboard", visibility=["app"])) + @server.tool( + app=AppConfig(resource_uri="ui://dashboard", visibility=["app", "model"]) + ) def dashboard() -> str: return "data" @@ -550,3 +561,64 @@ class TestIntegration: assert content_item.meta["ui"]["csp"]["resourceDomains"] == [ "https://unpkg.com" ] + + +# --------------------------------------------------------------------------- +# PrefabAppConfig +# --------------------------------------------------------------------------- + + +class TestPrefabAppConfig: + def test_default_sets_renderer_uri(self): + from fastmcp.apps import PrefabAppConfig + + config = PrefabAppConfig() + assert config.resource_uri == "ui://prefab/renderer.html" + + def test_merges_renderer_csp_with_user_csp(self): + from fastmcp.apps import PrefabAppConfig + + config = PrefabAppConfig( + csp=ResourceCSP(frame_domains=["https://example.com"]), + ) + assert config.resource_uri == "ui://prefab/renderer.html" + assert config.csp is not None + assert config.csp.frame_domains == ["https://example.com"] + + async def test_auto_registers_renderer_resource(self): + from fastmcp.apps import PrefabAppConfig + + server = FastMCP("test") + + @server.tool(app=PrefabAppConfig()) + def my_tool() -> str: + return "hello" + + resources = list(await server.list_resources()) + uris = [str(r.uri) for r in resources] + assert any("ui://prefab/renderer.html" in u for u in uris) + + async def test_equivalent_to_app_true(self): + """PrefabAppConfig() should produce the same tool metadata as app=True.""" + from fastmcp.apps import PrefabAppConfig + + server1 = FastMCP("test1") + server2 = FastMCP("test2") + + @server1.tool(app=True) + def tool_a() -> str: + return "a" + + @server2.tool(app=PrefabAppConfig()) + def tool_b() -> str: + return "b" + + tools1 = list(await server1.list_tools()) + tools2 = list(await server2.list_tools()) + + assert tools1[0].meta is not None + ui1 = tools1[0].meta.get("ui", {}) + assert tools2[0].meta is not None + ui2 = tools2[0].meta.get("ui", {}) + + assert ui1.get("resourceUri") == ui2.get("resourceUri") diff --git a/tests/test_apps_prefab.py b/tests/test_apps_prefab.py index 301141d05..8db9a2a97 100644 --- a/tests/test_apps_prefab.py +++ b/tests/test_apps_prefab.py @@ -14,12 +14,12 @@ from prefab_ui.components import Column, Heading, Text from prefab_ui.components.base import Component from fastmcp import Client, FastMCP +from fastmcp.apps import UI_MIME_TYPE, AppConfig from fastmcp.resources.types import TextResource -from fastmcp.server.apps import UI_MIME_TYPE, AppConfig from fastmcp.server.providers.local_provider.decorators.tools import ( PREFAB_RENDERER_URI, ) -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult # --------------------------------------------------------------------------- # convert_result @@ -39,9 +39,13 @@ class TestConvertResult: assert isinstance(result.content[0], TextContent) assert result.content[0].text == "[Rendered Prefab UI]" assert result.structured_content is not None - assert result.structured_content["version"] == "0.2" + assert result.structured_content["$prefab"]["version"] == "0.2" assert result.structured_content["state"] == {"name": "Alice"} - assert result.structured_content["view"]["type"] == "Column" + # PrefabApp wraps view in a pf-app-root Div + root = result.structured_content["view"] + assert root["type"] == "Div" + assert root["cssClass"] == "pf-app-root" + assert root["children"][0]["type"] == "Column" def test_bare_component(self): heading = Heading("World") @@ -51,8 +55,9 @@ class TestConvertResult: assert isinstance(result, ToolResult) assert result.structured_content is not None - assert result.structured_content["version"] == "0.2" - assert result.structured_content["view"]["type"] == "Heading" + assert result.structured_content["$prefab"]["version"] == "0.2" + assert result.structured_content["view"]["type"] == "Div" + assert result.structured_content["view"]["children"][0]["type"] == "Heading" def test_tool_result_with_prefab_structured_content(self): """ToolResult with PrefabApp as structured_content preserves custom text.""" @@ -66,8 +71,9 @@ class TestConvertResult: assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Custom fallback text" assert result.structured_content is not None - assert result.structured_content["version"] == "0.2" - assert result.structured_content["view"]["type"] == "Heading" + assert result.structured_content["$prefab"]["version"] == "0.2" + assert result.structured_content["view"]["type"] == "Div" + assert result.structured_content["view"]["children"][0]["type"] == "Heading" def test_tool_result_with_component_structured_content(self): """ToolResult with bare Component as structured_content.""" @@ -79,8 +85,9 @@ class TestConvertResult: assert isinstance(result.content[0], TextContent) assert result.content[0].text == "My text" assert result.structured_content is not None - assert result.structured_content["version"] == "0.2" - assert result.structured_content["view"]["type"] == "Heading" + assert result.structured_content["$prefab"]["version"] == "0.2" + assert result.structured_content["view"]["type"] == "Div" + assert result.structured_content["view"]["children"][0]["type"] == "Heading" def test_tool_result_passthrough(self): """ToolResult without prefab structured_content passes through unchanged.""" @@ -378,7 +385,7 @@ class TestIntegration: result = await client.call_tool("greet", {"name": "Alice"}) assert result.structured_content is not None - assert result.structured_content["version"] == "0.2" + assert result.structured_content["$prefab"]["version"] == "0.2" assert result.structured_content["state"] == {"name": "Alice"} async def test_tool_call_with_custom_text(self): @@ -399,7 +406,7 @@ class TestIntegration: "Greeting for Alice" in c.text for c in result.content if hasattr(c, "text") ) assert result.structured_content is not None - assert result.structured_content["version"] == "0.2" + assert result.structured_content["$prefab"]["version"] == "0.2" async def test_tools_list_includes_app_meta(self): mcp = FastMCP("test") diff --git a/tests/test_fastmcp_app.py b/tests/test_fastmcp_app.py new file mode 100644 index 000000000..8a49d659f --- /dev/null +++ b/tests/test_fastmcp_app.py @@ -0,0 +1,912 @@ +"""Tests for FastMCPApp — the composable application provider. + +Covers: +- @app.tool() decorator (visibility, calling patterns) +- @app.ui() decorator (model visibility, CSP auto-wiring) +- get_app_tool routing through provider chain +- Callable resolver (_resolve_tool_ref) +- Composition with namespaced servers +- Provider interface delegation +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from prefab_ui.app import ResolvedTool +from prefab_ui.components import Text + +from fastmcp import Client, FastMCP +from fastmcp.apps.app import ( + FastMCPApp, + _make_resolver, +) +from fastmcp.tools.base import Tool + +# --------------------------------------------------------------------------- +# @app.tool() decorator +# --------------------------------------------------------------------------- + + +class TestFastMCPAppInit: + def test_app_name_with_triple_underscore_rejected(self): + with pytest.raises(ValueError, match="must not contain '___'"): + FastMCPApp("my___app") + + def test_app_name_with_single_or_double_underscore_ok(self): + FastMCPApp("my_app") + FastMCPApp("my__app") + + +class TestAppTool: + def test_tool_bare_decorator(self): + app = FastMCPApp("test") + + @app.tool + def save(name: str) -> str: + return name + + assert save("alice") == "alice" + + def test_tool_empty_parens(self): + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return name + + assert save("alice") == "alice" + + def test_tool_custom_name(self): + app = FastMCPApp("test") + + @app.tool("custom_save") + def save(name: str) -> str: + return name + + assert save("alice") == "alice" + + def test_tool_name_kwarg(self): + app = FastMCPApp("test") + + @app.tool(name="my_tool") + def save(name: str) -> str: + return name + + assert save("alice") == "alice" + + def test_tool_name_conflict_raises(self): + app = FastMCPApp("test") + + with pytest.raises(TypeError): + + @app.tool("x", name="y") + def save() -> str: + return "" + + async def test_tool_registers_in_provider(self): + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return name + + tools = await app._list_tools() + assert len(tools) == 1 + assert tools[0].name == "save" + + async def test_tool_custom_name_in_provider(self): + app = FastMCPApp("test") + + @app.tool("custom_save") + def save(name: str) -> str: + return name + + tools = await app._list_tools() + assert len(tools) == 1 + assert tools[0].name == "custom_save" + + async def test_tool_has_app_name_in_meta(self): + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str) -> str: + return name + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + assert meta["fastmcp"]["app"] == "contacts" + + async def test_tool_default_visibility_app_only(self): + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return name + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + assert meta["ui"]["visibility"] == ["app"] + + async def test_tool_model_visibility(self): + app = FastMCPApp("test") + + @app.tool(model=True) + def query(search: str) -> list: + return [] + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + assert meta["ui"]["visibility"] == ["app", "model"] + + def test_tool_with_description(self): + app = FastMCPApp("test") + + @app.tool(description="Save a contact") + def save(name: str) -> str: + return name + + def test_tool_with_auth(self): + app = FastMCPApp("test") + check = AsyncMock(return_value=True) + + @app.tool(auth=check) + def save(name: str) -> str: + return name + + def test_tool_with_timeout(self): + app = FastMCPApp("test") + + @app.tool(timeout=30.0) + def slow_save(name: str) -> str: + return name + + +# --------------------------------------------------------------------------- +# @app.ui() decorator +# --------------------------------------------------------------------------- + + +class TestAppUI: + def test_ui_bare_decorator(self): + app = FastMCPApp("test") + + @app.ui + def dashboard() -> str: + return "hi" + + assert dashboard() == "hi" + + def test_ui_empty_parens(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "hi" + + assert dashboard() == "hi" + + def test_ui_custom_name(self): + app = FastMCPApp("test") + + @app.ui("my_dashboard") + def dashboard() -> str: + return "hi" + + assert dashboard() == "hi" + + async def test_ui_registers_in_provider(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + assert len(tools) == 1 + assert tools[0].name == "dashboard" + + async def test_ui_visibility_model_only(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + assert meta["ui"]["visibility"] == ["model"] + + async def test_ui_has_app_name_in_meta(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + assert meta["fastmcp"]["app"] == "test" + + async def test_ui_has_resource_uri(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + assert meta["ui"]["resourceUri"] == "ui://prefab/renderer.html" + + async def test_ui_has_csp(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + csp = meta["ui"].get("csp") + assert csp is not None + + async def test_ui_with_title_and_description(self): + app = FastMCPApp("test") + + @app.ui(title="My Dashboard", description="Shows data") + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + assert tools[0].title == "My Dashboard" + assert tools[0].description == "Shows data" + + async def test_ui_with_tags(self): + app = FastMCPApp("test") + + @app.ui(tags={"dashboard", "main"}) + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + assert tools[0].tags == {"dashboard", "main"} + + +# --------------------------------------------------------------------------- +# Callable resolver +# --------------------------------------------------------------------------- + + +class TestResolveToolRef: + def test_resolve_string_no_app_name(self): + """Without an app name, strings pass through unprefixed.""" + result = _make_resolver()("save_contact") + assert isinstance(result, ResolvedTool) + assert result.name == "save_contact" + + def test_resolve_string_with_app_name(self): + """With an app name, strings get the ___-prefix.""" + result = _make_resolver("Files")("store_files") + assert isinstance(result, ResolvedTool) + assert result.name == "Files___store_files" + + def test_resolve_string_already_prefixed(self): + """Strings that already contain ___ are not double-prefixed.""" + result = _make_resolver("Files")("Other___store_files") + assert isinstance(result, ResolvedTool) + assert result.name == "Other___store_files" + + def test_resolve_callable_no_app_name(self): + def my_tool(): + pass + + result = _make_resolver()(my_tool) + assert isinstance(result, ResolvedTool) + assert result.name == "my_tool" + + def test_resolve_callable_with_app_name(self): + """Callables also get the ___-prefix when an app name is set.""" + + def store_files(): + pass + + result = _make_resolver("Files")(store_files) + assert isinstance(result, ResolvedTool) + assert result.name == "Files___store_files" + + def test_resolve_fastmcp_metadata(self): + from fastmcp.tools.function_tool import ToolMeta + + def my_tool(): + pass + + my_tool.__fastmcp__ = ToolMeta(name="custom_name") # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + + result = _make_resolver()(my_tool) + assert isinstance(result, ResolvedTool) + assert result.name == "custom_name" + + def test_resolve_fastmcp_metadata_with_app_name(self): + from fastmcp.tools.function_tool import ToolMeta + + def my_tool(): + pass + + my_tool.__fastmcp__ = ToolMeta(name="custom_name") # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + + result = _make_resolver("MyApp")(my_tool) + assert isinstance(result, ResolvedTool) + assert result.name == "MyApp___custom_name" + + def test_resolve_unresolvable_raises(self): + with pytest.raises(ValueError): + _make_resolver()(42) + + +# --------------------------------------------------------------------------- +# get_app_tool — provider chain routing +# --------------------------------------------------------------------------- + + +class TestGetAppTool: + async def test_direct_lookup(self): + """FastMCPApp.get_app_tool finds tools by app name + tool name.""" + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str) -> str: + return name + + tool = await app.get_app_tool("contacts", "save") + assert tool is not None + assert tool.name == "save" + + async def test_wrong_app_returns_none(self): + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str) -> str: + return name + + assert await app.get_app_tool("billing", "save") is None + + async def test_wrong_tool_returns_none(self): + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str) -> str: + return name + + assert await app.get_app_tool("contacts", "missing") is None + + async def test_two_apps_no_collision(self): + """Two apps with the same tool name are disambiguated.""" + app1 = FastMCPApp("contacts") + app2 = FastMCPApp("billing") + + @app1.tool() + def save(name: str) -> str: + return f"contact: {name}" + + @app2.tool("save") + def save_billing(amount: int) -> str: + return f"invoice: {amount}" + + server = FastMCP("Platform") + server.add_provider(app1) + server.add_provider(app2) + + t1 = await server.get_app_tool("contacts", "save") + t2 = await server.get_app_tool("billing", "save") + assert t1 is not None + assert t2 is not None + assert t1 is not t2 + + async def test_survives_namespace_transform(self): + """get_app_tool bypasses namespace transforms.""" + app = FastMCPApp("crm") + + @app.tool() + def save_contact(name: str) -> str: + return name + + server = FastMCP("Platform") + server.add_provider(app, namespace="crm") + + # Normal get_tool with untransformed name fails + tool = await server.get_tool("save_contact") + assert tool is None + + # get_app_tool bypasses transforms + tool = await server.get_app_tool("crm", "save_contact") + assert tool is not None + assert tool.name == "save_contact" + + async def test_ui_tool_not_findable_via_get_app_tool(self): + """@app.ui() tools have model visibility and should NOT be + returned by get_app_tool (only app-visible tools are).""" + app = FastMCPApp("dashboard") + + @app.ui() + def show() -> str: + return "ui" + + tool = await app.get_app_tool("dashboard", "show") + assert tool is None + + async def test_model_visible_tool_findable(self): + """@app.tool(model=True) has app visibility and IS findable.""" + app = FastMCPApp("test") + + @app.tool(model=True) + def query(q: str) -> str: + return q + + tool = await app.get_app_tool("test", "query") + assert tool is not None + + +# --------------------------------------------------------------------------- +# Provider interface +# --------------------------------------------------------------------------- + + +class TestProviderInterface: + async def test_list_tools_empty(self): + app = FastMCPApp("test") + assert await app._list_tools() == [] + + async def test_list_resources_empty(self): + app = FastMCPApp("test") + assert list(await app._list_resources()) == [] + + async def test_get_tool_by_name(self): + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return name + + tool = await app._get_tool("save") + assert tool is not None + assert tool.name == "save" + + async def test_get_tool_missing_returns_none(self): + app = FastMCPApp("test") + assert await app._get_tool("missing") is None + + +# --------------------------------------------------------------------------- +# call_tool with app_name routing +# --------------------------------------------------------------------------- + + +class TestCallToolAppRouting: + async def test_call_tool_with_app_name(self): + """Server.call_tool routes via get_app_tool when app_name is set.""" + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str) -> str: + return f"saved {name}" + + server = FastMCP("Platform") + server.add_provider(app) + + result = await server.call_tool("contacts___save", {"name": "alice"}) + assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_call_tool_without_app_name_model_visible(self): + """Regular name-based resolution works for model-visible tools.""" + app = FastMCPApp("test") + + @app.tool(model=True) + def save(name: str) -> str: + return f"saved {name}" + + server = FastMCP("Platform") + server.add_provider(app) + + result = await server.call_tool("save", {"name": "bob"}) + assert result.content[0].text == "saved bob" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_app_name_survives_namespace(self): + """app_name routing bypasses namespace transforms.""" + app = FastMCPApp("crm") + + @app.tool() + def save_contact(name: str) -> str: + return f"saved {name}" + + server = FastMCP("Platform") + server.add_provider(app, namespace="crm") + + result = await server.call_tool("crm___save_contact", {"name": "alice"}) + assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_namespaced_name_also_works(self): + """Namespaced tool name works through normal resolution.""" + app = FastMCPApp("crm") + + @app.tool(model=True) + def save_contact(name: str) -> str: + return f"saved {name}" + + server = FastMCP("Platform") + server.add_provider(app, namespace="crm") + + result = await server.call_tool("crm_save_contact", {"name": "bob"}) + assert result.content[0].text == "saved bob" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_app_name_auth_blocks_unauthorized(self): + """Auth checks run even when routing via app_name.""" + from fastmcp.exceptions import NotFoundError + from fastmcp.server.context import _current_transport + + app = FastMCPApp("test") + deny_all = AsyncMock(return_value=False) + + @app.tool(auth=deny_all) + def secret() -> str: + return "classified" + + server = FastMCP("Platform") + server.add_provider(app) + + token = _current_transport.set("streamable-http") + try: + with pytest.raises(NotFoundError): + await server.call_tool("test___secret", {}) + finally: + _current_transport.reset(token) + + async def test_two_apps_same_tool_name_routed_correctly(self): + """Two apps with same tool name disambiguated by app_name.""" + contacts = FastMCPApp("contacts") + billing = FastMCPApp("billing") + + @contacts.tool() + def save(name: str) -> str: + return f"contact: {name}" + + @billing.tool("save") + def save_billing(amount: str) -> str: + return f"invoice: {amount}" + + server = FastMCP("Platform") + server.add_provider(contacts) + server.add_provider(billing) + + r1 = await server.call_tool("contacts___save", {"name": "alice"}) + r2 = await server.call_tool("billing___save", {"amount": "100"}) + + assert r1.content[0].text == "contact: alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert r2.content[0].text == "invoice: 100" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_deeply_nested_app(self): + """App tool is found even through multiple levels of nesting.""" + app = FastMCPApp("deep") + + @app.tool() + def hidden(x: str) -> str: + return x + + inner = FastMCP("Inner") + inner.add_provider(app, namespace="app") + + outer = FastMCP("Outer") + outer.mount(inner, namespace="inner") + + # Normal resolution: would need "inner_app_hidden" + # App routing: bypasses all transforms + result = await outer.call_tool("deep___hidden", {"x": "found"}) + assert result.content[0].text == "found" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + +# --------------------------------------------------------------------------- +# App-only tool filtering from server list_tools / get_tool +# --------------------------------------------------------------------------- + + +class TestAppOnlyToolFiltering: + async def test_app_only_tool_hidden_from_list_tools(self): + """@app.tool() (visibility=["app"]) should not appear in server.list_tools().""" + app = FastMCPApp("crm") + + @app.tool() + def save_contact(name: str) -> str: + return name + + server = FastMCP("Platform") + server.add_provider(app) + + tools = await server.list_tools() + names = [t.name for t in tools] + assert "save_contact" not in names + + async def test_model_visible_tool_in_list_tools(self): + """@app.tool(model=True) (visibility=["app","model"]) appears in list_tools.""" + app = FastMCPApp("crm") + + @app.tool(model=True) + def query(search: str) -> list[str]: + return [search] + + server = FastMCP("Platform") + server.add_provider(app) + + tools = await server.list_tools() + names = [t.name for t in tools] + assert "query" in names + + async def test_ui_tool_in_list_tools(self): + """@app.ui() (visibility=["model"]) appears in list_tools.""" + app = FastMCPApp("dashboard") + + @app.ui() + def show_dashboard() -> str: + return "dashboard" + + server = FastMCP("Platform") + server.add_provider(app) + + tools = await server.list_tools() + 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.""" + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str) -> str: + return f"saved {name}" + + 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 app_name routing + result = await server.call_tool("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") + + @app.tool() + def save_contact(name: str) -> str: + return name + + server = FastMCP("Platform") + server.add_provider(app) + + tool = await server.get_tool("save_contact") + assert tool is None + + async def test_app_only_tool_hidden_with_namespace(self): + """App-only tools hidden even when accessed through a namespace.""" + app = FastMCPApp("crm") + + @app.tool() + def save(name: str) -> str: + return name + + server = FastMCP("Platform") + server.add_provider(app, namespace="crm") + + tools = await server.list_tools() + names = [t.name for t in tools] + assert "crm_save" not in names + + +# --------------------------------------------------------------------------- +# End-to-end via Client +# --------------------------------------------------------------------------- + + +class TestEndToEnd: + async def test_ui_tool_visible_to_client(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + server = FastMCP("Platform") + server.add_provider(app) + + async with Client(server) as client: + tools = await client.list_tools() + names = [t.name for t in tools] + assert "dashboard" in names + + async def test_app_tool_model_true_visible(self): + app = FastMCPApp("test") + + @app.tool(model=True) + def query(search: str) -> list: + return [search] + + server = FastMCP("Platform") + server.add_provider(app) + + async with Client(server) as client: + tools = await client.list_tools() + names = [t.name for t in tools] + assert "query" in names + + +# --------------------------------------------------------------------------- +# .run() convenience +# --------------------------------------------------------------------------- + + +class TestRun: + def test_repr(self): + app = FastMCPApp("Dashboard") + assert repr(app) == "FastMCPApp('Dashboard')" + + +# --------------------------------------------------------------------------- +# add_tool programmatic +# --------------------------------------------------------------------------- + + +class TestAddTool: + async def test_add_tool_from_function(self): + app = FastMCPApp("test") + + def save(name: str) -> str: + return name + + tool = app.add_tool(save) + assert tool.name == "save" + + tools = await app._list_tools() + assert len(tools) == 1 + + async def test_add_tool_tagged_with_app_name(self): + app = FastMCPApp("myapp") + + def save(name: str) -> str: + return name + + tool = app.add_tool(save) + assert tool.meta is not None + assert tool.meta["fastmcp"]["app"] == "myapp" + + async def test_add_tool_findable_via_get_app_tool(self): + app = FastMCPApp("myapp") + + def save(name: str) -> str: + return name + + app.add_tool(save) + tool = await app.get_app_tool("myapp", "save") + assert tool is not None + + async def test_add_tool_object(self): + app = FastMCPApp("test") + tool = Tool.from_function(lambda x: x, name="my_tool") + added = app.add_tool(tool) + assert added.name == "my_tool" + + tools = await app._list_tools() + assert len(tools) == 1 + + +# --------------------------------------------------------------------------- +# Composition +# --------------------------------------------------------------------------- + + +class TestComposition: + async def test_multiple_apps_on_one_server(self): + crm = FastMCPApp("CRM") + billing = FastMCPApp("Billing") + + @crm.tool() + def save_contact(name: str) -> str: + return name + + @billing.tool() + def create_invoice(amount: int) -> int: + return amount + + server = FastMCP("Platform") + server.add_provider(crm, namespace="crm") + server.add_provider(billing, namespace="billing") + + r1 = await server.call_tool("CRM___save_contact", {"name": "alice"}) + r2 = await server.call_tool("Billing___create_invoice", {"amount": 100}) + + assert r1.content[0].text == "alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert r2.content[0].text == "100" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + async def test_ui_and_tool_on_same_app(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "ui" + + @app.tool() + def save(name: str) -> str: + return name + + tools = await app._list_tools() + assert len(tools) == 2 + names = {t.name for t in tools} + assert names == {"dashboard", "save"} + + async def test_ui_registers_prefab_renderer_resource(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "ui" + + resources = await app._list_resources() + uris = [str(r.uri) for r in resources] + assert any("ui://prefab/renderer.html" in uri for uri in uris) + + +# --------------------------------------------------------------------------- +# Integration: full end-to-end with client, namespacing, and structured content +# --------------------------------------------------------------------------- + + +class TestAppIntegration: + async def test_full_app_lifecycle_through_client(self): + """End-to-end: mount an app on a namespaced server, call UI tool + through a client (verifying structured_content is returned), then + call the backend tool via the ___-prefixed name.""" + app = FastMCPApp("contacts") + + @app.ui() + def contact_form() -> Text: + return Text(content="Enter contact details") + + @app.tool() + def save_contact(name: str, email: str) -> dict[str, str]: + return {"name": name, "email": email} + + 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. + 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 + + # Call the UI tool through the client and check structured_content + result = await client.call_tool_mcp("crm_contact_form", {}) + sc = result.structuredContent + assert sc is not None + + # Call the backend tool via prefixed name + # (bypasses namespace transforms and visibility filtering) + backend_result = await server.call_tool( + "contacts___save_contact", + {"name": "Alice", "email": "alice@example.com"}, + ) + result_text = backend_result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "Alice" in result_text + assert "alice@example.com" in result_text diff --git a/tests/test_json_schema_generation.py b/tests/test_json_schema_generation.py index 9cf10a0a5..5fdd9ae23 100644 --- a/tests/test_json_schema_generation.py +++ b/tests/test_json_schema_generation.py @@ -7,8 +7,8 @@ using SkipJsonSchema annotations. from fastmcp.prompts.function_prompt import FunctionPrompt from fastmcp.resources.function_resource import FunctionResource from fastmcp.resources.template import FunctionResourceTemplate +from fastmcp.tools.base import Tool from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool import Tool from fastmcp.tools.tool_transform import TransformedTool diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index 2c7f133a7..7b2998866 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -36,14 +36,18 @@ from fastmcp.mcp_config import ( StdioMCPServer, TransformingStdioMCPServer, ) -from fastmcp.tools.tool import Tool as FastMCPTool +from fastmcp.tools.base import Tool as FastMCPTool -# Skip all tests in this file on Windows - they spawn subprocess servers via stdio -# which has process lifecycle issues on Windows -pytestmark = pytest.mark.skipif( - sys.platform.startswith("win32"), - reason="Windows has process lifecycle issues with stdio subprocesses", -) +# These tests spawn subprocess servers via stdio which can be slow under +# parallel CI load. Give them more headroom than the 5s default, and skip +# entirely on Windows due to process lifecycle issues. +pytestmark = [ + pytest.mark.timeout(15), + pytest.mark.skipif( + sys.platform.startswith("win32"), + reason="Windows has process lifecycle issues with stdio subprocesses", + ), +] def running_under_debugger(): @@ -337,7 +341,7 @@ async def test_multi_client_parallel_calls(tmp_path: Path): exceptions = [result for result in results if isinstance(result, Exception)] assert len(exceptions) == 0 assert len(results) == 40 - assert all(len(result) == 2 for result in results) # type: ignore[arg-type] + assert all(len(result) == 2 for result in results) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] async def _wait_for_process_exit(pid: int, timeout: float = 3.0) -> None: @@ -678,7 +682,7 @@ async def test_canonical_multi_client_with_transforms(tmp_path: Path): "command": "python", "args": [str(script_path)], }, - } # type: ignore[reportUnknownArgumentType] + } # type: ignore[reportUnknownArgumentType] # ty:ignore[invalid-argument-type] ) client = Client(config) @@ -1003,6 +1007,153 @@ async def test_single_server_config_transport(): assert len(transport._transports) == 1 +@pytest.mark.parametrize( + "server_order", + [ + {"good_server": True, "bad_server": False}, + {"bad_server": False, "good_server": True}, + ], + ids=["good_first", "bad_first"], +) +async def test_multi_server_partial_failure(tmp_path: Path, server_order: dict): + """When one server fails to connect, the others should still work.""" + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + servers = {} + for name, is_good in server_order.items(): + if is_good: + servers[name] = { + "command": "python", + "args": [str(script_path)], + } + else: + servers[name] = { + "command": "this-command-does-not-exist-anywhere", + "args": [], + } + + client = Client({"mcpServers": servers}) + async with client: + tools = await client.list_tools() + tool_names = [t.name for t in tools] + assert "good_server_add" in tool_names + assert len(tools) == 1 + + +async def test_multi_server_partial_failure_logs_warning(tmp_path: Path, caplog): + """A warning should be logged when a server fails to connect.""" + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "good_server": { + "command": "python", + "args": [str(script_path)], + }, + "bad_server": { + "command": "this-command-does-not-exist-anywhere", + "args": [], + }, + } + } + + with caplog.at_level(logging.WARNING): + async with Client(config): + pass + + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "bad_server" in r.message + ] + assert len(warning_records) == 1 + + +async def test_multi_server_all_fail(): + """When all servers fail to connect, a ConnectionError should be raised.""" + config = MCPConfig( + mcpServers={ + "bad_1": StdioMCPServer( + command="this-command-does-not-exist-anywhere", + args=[], + ), + "bad_2": StdioMCPServer( + command="this-other-command-does-not-exist-either", + args=[], + ), + } + ) + + transport = MCPConfigTransport(config) + with pytest.raises(ConnectionError, match="All MCP servers failed to connect"): + async with transport.connect_session(): + pass + + +async def test_multi_server_partial_failure_cleanup(tmp_path: Path): + """Transports for failed servers should not leak into _transports.""" + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def ping() -> str: + return "pong" + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "working": { + "command": "python", + "args": [str(script_path)], + }, + "broken": { + "command": "this-command-does-not-exist-anywhere", + "args": [], + }, + } + } + + transport = MCPConfigTransport(config) + async with transport.connect_session(): + assert len(transport._transports) == 1 + + def sample_tool_fn(arg1: int, arg2: str) -> str: return f"Hello, world! {arg1} {arg2}" diff --git a/tests/tools/test_standalone_decorator.py b/tests/tools/test_standalone_decorator.py index de05aab9d..b9cc4a67b 100644 --- a/tests/tools/test_standalone_decorator.py +++ b/tests/tools/test_standalone_decorator.py @@ -115,7 +115,7 @@ class TestToolDecorator: """@tool should raise if both positional and keyword name are given.""" with pytest.raises(TypeError, match="Cannot specify.*both.*argument.*keyword"): - @tool("name1", name="name2") # type: ignore[call-overload] + @tool("name1", name="name2") # type: ignore[call-overload] # ty:ignore[invalid-argument-type] def my_tool() -> str: return "hello" diff --git a/tests/tools/test_tool_future_annotations.py b/tests/tools/test_tool_future_annotations.py index 10ca9c57a..acddc76c4 100644 --- a/tests/tools/test_tool_future_annotations.py +++ b/tests/tools/test_tool_future_annotations.py @@ -7,7 +7,7 @@ from pydantic import Field from fastmcp import Context, FastMCP from fastmcp.client import Client -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.base import ToolResult from fastmcp.utilities.types import Image fastmcp_server = FastMCP() diff --git a/tests/tools/tool/test_callable.py b/tests/tools/tool/test_callable.py index cffe8ab42..83dd149ce 100644 --- a/tests/tools/tool/test_callable.py +++ b/tests/tools/tool/test_callable.py @@ -4,7 +4,7 @@ import threading from mcp.types import TextContent from fastmcp import Context, FastMCP -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool class TestToolCallable: diff --git a/tests/tools/tool/test_content.py b/tests/tools/tool/test_content.py index d08720f80..0ffe54385 100644 --- a/tests/tools/tool/test_content.py +++ b/tests/tools/tool/test_content.py @@ -13,7 +13,7 @@ from mcp.types import ( ) from pydantic import AnyUrl, BaseModel -from fastmcp.tools.tool import Tool, _convert_to_content +from fastmcp.tools.base import Tool, _convert_to_content from fastmcp.utilities.types import Audio, File, Image diff --git a/tests/tools/tool/test_output_schema.py b/tests/tools/tool/test_output_schema.py index fc9468a04..11d850a7f 100644 --- a/tests/tools/tool/test_output_schema.py +++ b/tests/tools/tool/test_output_schema.py @@ -7,7 +7,7 @@ from mcp.types import AudioContent, EmbeddedResource, ImageContent, TextContent from pydantic import AnyUrl, BaseModel, Field, TypeAdapter from typing_extensions import TypedDict -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.types import Audio, File, Image @@ -123,6 +123,34 @@ class TestToolFromFunctionOutputSchema: # Image, Audio, File types don't generate output schemas since they're converted to content directly assert tool.output_schema is None + async def test_tool_result_return_annotation_no_output_schema(self): + def func() -> ToolResult: + return ToolResult(content="hello") + + tool = Tool.from_function(func) + assert tool.output_schema is None + + async def test_tool_result_subclass_return_annotation_no_output_schema(self): + class MyToolResult(ToolResult): + def __init__(self, data: str): + super().__init__(structured_content={"content": data}) + + def func() -> MyToolResult: + return MyToolResult("hello") + + tool = Tool.from_function(func) + assert tool.output_schema is None + + async def test_optional_tool_result_subclass_no_output_schema(self): + class MyToolResult(ToolResult): + pass + + def func() -> MyToolResult | None: + return None + + tool = Tool.from_function(func) + assert tool.output_schema is None + async def test_dataclass_return_annotation(self): @dataclass class Person: @@ -532,3 +560,49 @@ class TestToolFromFunctionOutputSchema: ValueError, match="Output schemas must represent object types" ): Tool.from_function(func, output_schema=schema) + + +class TestWrapResultMeta: + async def test_list_return_includes_wrap_result_meta(self): + """A tool returning list[dict] should set wrap_result in meta.""" + + def func() -> list[dict]: + return [{"a": 1}, {"b": 2}] + + tool = Tool.from_function(func) + result = await tool.run({}) + assert result.structured_content == {"result": [{"a": 1}, {"b": 2}]} + assert result.meta == {"fastmcp": {"wrap_result": True}} + + async def test_int_return_includes_wrap_result_meta(self): + """A tool returning int should set wrap_result in meta.""" + + def func() -> int: + return 42 + + tool = Tool.from_function(func) + result = await tool.run({}) + assert result.structured_content == {"result": 42} + assert result.meta == {"fastmcp": {"wrap_result": True}} + + async def test_dict_return_does_not_include_wrap_result_meta(self): + """A tool returning dict should NOT set wrap_result in meta.""" + + def func() -> dict[str, int]: + return {"value": 42} + + tool = Tool.from_function(func) + result = await tool.run({}) + assert result.structured_content == {"value": 42} + assert result.meta is None + + async def test_no_schema_dict_return_no_meta(self): + """A tool without output schema returning dict should not set meta.""" + + def func(): + return {"key": "val"} + + tool = Tool.from_function(func) + result = await tool.run({}) + assert result.structured_content == {"key": "val"} + assert result.meta is None diff --git a/tests/tools/tool/test_results.py b/tests/tools/tool/test_results.py index da5973cd8..fb8de48ae 100644 --- a/tests/tools/tool/test_results.py +++ b/tests/tools/tool/test_results.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult class TestToolResultCasting: diff --git a/tests/tools/tool/test_title.py b/tests/tools/tool/test_title.py index 30fa5b3f6..f69a9418c 100644 --- a/tests/tools/tool/test_title.py +++ b/tests/tools/tool/test_title.py @@ -1,4 +1,4 @@ -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool class TestToolTitle: diff --git a/tests/tools/tool/test_tool.py b/tests/tools/tool/test_tool.py index e2976b674..b9c14a9f3 100644 --- a/tests/tools/tool/test_tool.py +++ b/tests/tools/tool/test_tool.py @@ -10,7 +10,7 @@ from mcp.types import ( ) from pydantic import BaseModel -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.types import Audio, File, Image diff --git a/tests/tools/tool_transform/test_schemas.py b/tests/tools/tool_transform/test_schemas.py index 41aa7bbe8..b557186a5 100644 --- a/tests/tools/tool_transform/test_schemas.py +++ b/tests/tools/tool_transform/test_schemas.py @@ -7,8 +7,8 @@ from mcp.types import TextContent from pydantic import BaseModel, Field, TypeAdapter from fastmcp.tools import Tool, forward +from fastmcp.tools.base import ToolResult from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool import ToolResult from fastmcp.tools.tool_transform import ( ArgTransform, TransformedTool, diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index f6167a8ec..810225121 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -10,8 +10,8 @@ from pydantic import BaseModel, Field from fastmcp import FastMCP from fastmcp.client.client import Client from fastmcp.tools import Tool, forward, forward_raw, tool +from fastmcp.tools.base import ToolResult from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool import ToolResult from fastmcp.tools.tool_transform import ( ArgTransform, TransformedTool, @@ -428,13 +428,13 @@ async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool): async def test_forward_outside_context_raises_error(): """Test that forward() raises error when called outside transform context.""" - with pytest.raises(RuntimeError, match="forward\(\) can only be called"): + with pytest.raises(RuntimeError, match=r"forward\(\) can only be called"): await forward(x=1) async def test_forward_raw_outside_context_raises_error(): """Test that forward_raw() raises error when called outside transform context.""" - with pytest.raises(RuntimeError, match="forward_raw\(\) can only be called"): + with pytest.raises(RuntimeError, match=r"forward_raw\(\) can only be called"): await forward_raw(x=1) @@ -482,6 +482,20 @@ def test_transform_args_creates_duplicate_names(add_tool): ) +def test_transform_args_collision_with_passthrough_name(add_tool): + """Test that renaming to a passthrough parameter name raises ValueError.""" + with pytest.raises( + ValueError, + match="Multiple arguments would be mapped to the same names: old_y", + ): + Tool.from_tool( + add_tool, + transform_args={ + "old_x": ArgTransform(name="old_y"), + }, + ) + + def test_function_without_kwargs_missing_params(add_tool): """Test that function missing required transformed parameters raises ValueError.""" diff --git a/tests/utilities/json_schema_type/test_advanced.py b/tests/utilities/json_schema_type/test_advanced.py index db32e40a0..9240e04b5 100644 --- a/tests/utilities/json_schema_type/test_advanced.py +++ b/tests/utilities/json_schema_type/test_advanced.py @@ -478,7 +478,7 @@ class TestEdgeCases: Type = json_schema_to_type(schema) validator = TypeAdapter(Type) result = validator.validate_python({"name": "test"}) - assert result.name == "test" # type: ignore[attr-defined] + assert result.name == "test" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] def test_recursive_defaults(self): schema = { @@ -494,8 +494,8 @@ class TestEdgeCases: Type = json_schema_to_type(schema) validator = TypeAdapter(Type) result = validator.validate_python({}) - assert result.node.value == "default" # type: ignore[attr-defined] - assert result.node.next is None # type: ignore[attr-defined] + assert result.node.value == "default" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert result.node.next is None # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] def test_mixed_type_array(self): schema = { @@ -606,9 +606,9 @@ class TestNameHandling: result = validator.validate_python( {"name": "parent", "child": {"name": "child", "child": None}} ) - assert result.name == "parent" # type: ignore[attr-defined] - assert result.child.name == "child" # type: ignore[attr-defined] - assert result.child.child is None # type: ignore[attr-defined] + assert result.name == "parent" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert result.child.name == "child" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert result.child.child is None # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] class TestAdditionalProperties: @@ -788,17 +788,17 @@ class TestAdditionalProperties: result = validator.validate_python(data) # Check top-level extra field (BaseModel) - assert result.top_level_extra == "preserved" # type: ignore[attr-defined] + assert result.top_level_extra == "preserved" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] # Check nested user extra field (BaseModel) - assert result.user.name == "Alice" # type: ignore[attr-defined] - assert result.user.extra_user_field == "value" # type: ignore[attr-defined] + assert result.user.name == "Alice" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert result.user.extra_user_field == "value" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] # Check nested settings - should be dataclass - assert result.settings.theme == "dark" # type: ignore[attr-defined] + assert result.settings.theme == "dark" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] # Note: When nested in BaseModel with extra='allow', Pydantic may preserve extra fields # even on dataclass children. The important thing is that settings is still a dataclass. - assert not issubclass(type(result.settings), BaseModel) # type: ignore[attr-defined] + assert not issubclass(type(result.settings), BaseModel) # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] def test_additional_properties_false_vs_missing(self): """Test difference between additionalProperties: false and missing additionalProperties""" @@ -840,9 +840,9 @@ class TestAdditionalProperties: # Test with extra fields and defaults result = validator.validate_python({"extra": "field"}) - assert result.name == "anonymous" # type: ignore[attr-defined] - assert result.age == 0 # type: ignore[attr-defined] - assert result.extra == "field" # type: ignore[attr-defined] + assert result.name == "anonymous" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert result.age == 0 # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert result.extra == "field" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] def test_additional_properties_type_consistency(self): """Test that the same schema always returns the same type""" @@ -898,7 +898,7 @@ class TestFieldsWithDefaults: generated_type = json_schema_to_type(schema) validator = TypeAdapter(generated_type) result = validator.validate_python({}) - assert result.flag is False # type: ignore[attr-defined] + assert result.flag is False # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] def test_field_with_default_accepts_explicit_value(self): """Test that fields with defaults accept explicit values.""" @@ -910,4 +910,4 @@ class TestFieldsWithDefaults: generated_type = json_schema_to_type(schema) validator = TypeAdapter(generated_type) result = validator.validate_python({"flag": True}) - assert result.flag is True # type: ignore[attr-defined] + assert result.flag is True # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] diff --git a/tests/utilities/openapi/test_director.py b/tests/utilities/openapi/test_director.py index 849c91ef4..56008b241 100644 --- a/tests/utilities/openapi/test_director.py +++ b/tests/utilities/openapi/test_director.py @@ -1,5 +1,8 @@ """Unit tests for RequestDirector.""" +import json +from urllib.parse import unquote + import pytest from jsonschema_path import SchemaPath @@ -194,8 +197,6 @@ class TestRequestDirector: ) # httpx normalizes headers to lowercase # Check body - import json - assert request.content is not None body_data = json.loads(request.content) assert body_data["title"] == "Updated Title" @@ -215,8 +216,6 @@ class TestRequestDirector: assert "123" in str(request.url) # Path ID should be 123 # Check body - import json - body_data = json.loads(request.content) assert body_data["id"] == 456 # Body ID should be 456 assert body_data["name"] == "John Doe" @@ -240,8 +239,6 @@ class TestRequestDirector: headers = dict(request.headers) if request.headers else {} assert "X-Client-Version" not in headers - import json - body_data = json.loads(request.content) assert body_data["title"] == "Required Title" assert "description" not in body_data # Should not include None description @@ -309,8 +306,6 @@ class TestRequestDirector: assert request.method == "POST" assert "123" in str(request.url) - import json - body_data = json.loads(request.content) assert body_data["name"] == "John Doe" @@ -377,12 +372,582 @@ class TestRequestDirector: assert request.method == "POST" # Should wrap in object when multiple properties but schema is not object - import json - body_data = json.loads(request.content) assert body_data == {"prop1": "value1", "prop2": "value2"} +class TestContentTypeHandling: + """Test that request Content-Type respects the OpenAPI spec.""" + + @pytest.fixture + def director(self, basic_openapi_30_spec): + spec = SchemaPath.from_dict(basic_openapi_30_spec) + return RequestDirector(spec) + + def test_application_json_uses_httpx_json(self, director): + """Standard application/json uses httpx's json= parameter.""" + route = HTTPRoute( + path="/items", + method="PATCH", + operation_id="update_item", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + }, + ), + parameter_map={ + "name": {"location": "body", "openapi_name": "name"}, + }, + ) + + request = director.build(route, {"name": "test"}, "https://example.com") + assert request.headers["content-type"] == "application/json" + assert json.loads(request.content) == {"name": "test"} + + def test_json_patch_content_type_preserved(self, director): + """application/json-patch+json is sent as the Content-Type header.""" + route = HTTPRoute( + path="/items/{id}", + method="PATCH", + operation_id="patch_item", + parameters=[ + ParameterInfo( + name="id", + location="path", + required=True, + schema={"type": "string"}, + ), + ], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json-patch+json": { + "type": "array", + "items": { + "type": "object", + "properties": { + "op": {"type": "string"}, + "path": {"type": "string"}, + "value": {}, + }, + }, + } + }, + ), + parameter_map={ + "id": {"location": "path", "openapi_name": "id"}, + "body": {"location": "body", "openapi_name": "body"}, + }, + ) + + patch_ops = [{"op": "replace", "path": "/name", "value": "new-name"}] + request = director.build( + route, {"id": "123", "body": patch_ops}, "https://example.com" + ) + + assert request.headers["content-type"] == "application/json-patch+json" + assert json.loads(request.content) == patch_ops + + def test_custom_json_content_type_with_dict_body(self, director): + """Any non-standard JSON content type gets the correct header.""" + route = HTTPRoute( + path="/items", + method="POST", + operation_id="create_item", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/merge-patch+json": { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + }, + ), + parameter_map={ + "name": {"location": "body", "openapi_name": "name"}, + }, + ) + + request = director.build(route, {"name": "test"}, "https://example.com") + assert request.headers["content-type"] == "application/merge-patch+json" + assert json.loads(request.content) == {"name": "test"} + + def test_custom_content_type_preserves_other_headers(self, director): + """Custom content type doesn't clobber other headers from parameters.""" + route = HTTPRoute( + path="/items", + method="PATCH", + operation_id="patch_item", + parameters=[ + ParameterInfo( + name="X-Request-Id", + location="header", + required=True, + schema={"type": "string"}, + ), + ], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json-patch+json": { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + }, + ), + parameter_map={ + "X-Request-Id": { + "location": "header", + "openapi_name": "X-Request-Id", + }, + "name": {"location": "body", "openapi_name": "name"}, + }, + ) + + request = director.build( + route, + {"X-Request-Id": "abc-123", "name": "test"}, + "https://example.com", + ) + assert request.headers["content-type"] == "application/json-patch+json" + assert request.headers["x-request-id"] == "abc-123" + + def test_no_request_body_info_defaults_to_json(self, director): + """When route has no request_body metadata, dict body uses application/json.""" + route = HTTPRoute( + path="/items", + method="POST", + operation_id="create_item", + parameter_map={ + "name": {"location": "body", "openapi_name": "name"}, + }, + ) + + request = director.build(route, {"name": "test"}, "https://example.com") + assert request.headers["content-type"] == "application/json" + + def test_non_json_content_type_falls_through(self, director): + """Non-JSON types like multipart/form-data don't get JSON-serialized.""" + route = HTTPRoute( + path="/upload", + method="POST", + operation_id="upload", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "multipart/form-data": { + "type": "object", + "properties": {"file": {"type": "string"}}, + } + }, + ), + parameter_map={ + "file": {"location": "body", "openapi_name": "file"}, + }, + ) + + request = director.build(route, {"file": "data"}, "https://example.com") + # Should fall through to httpx's json= path (not manually serialized + # with a multipart/form-data header), since the content type isn't + # JSON-compatible. + assert request.headers["content-type"] == "application/json" + + +class TestQueryParameterSerialization: + """Test that query parameters respect OpenAPI explode/style settings.""" + + @pytest.fixture + def director(self, basic_openapi_30_spec): + spec = SchemaPath.from_dict(basic_openapi_30_spec) + return RequestDirector(spec) + + def test_explode_true_repeats_keys(self, director): + """Default behavior: explode=true sends values=a&values=b.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="values", + location="query", + required=True, + schema={"type": "array", "items": {"type": "string"}}, + explode=True, + ) + ], + parameter_map={ + "values": {"location": "query", "openapi_name": "values"}, + }, + ) + + request = director.build( + route, {"values": ["hello", "world"]}, "https://example.com" + ) + url = str(request.url) + assert "values=hello" in url + assert "values=world" in url + + def test_explode_false_comma_joins(self, director): + """explode=false sends values=hello,world.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="values", + location="query", + required=True, + schema={"type": "array", "items": {"type": "string"}}, + explode=False, + ) + ], + parameter_map={ + "values": {"location": "query", "openapi_name": "values"}, + }, + ) + + request = director.build( + route, {"values": ["hello", "world"]}, "https://example.com" + ) + url = str(request.url) + assert "values=hello%2Cworld" in url or "values=hello,world" in url + # Must NOT have repeated keys + assert url.count("values=") == 1 + + def test_explode_none_defaults_to_true(self, director): + """When explode is unset, OpenAPI default for form style is explode=true.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="tags", + location="query", + required=True, + schema={"type": "array", "items": {"type": "string"}}, + explode=None, + ) + ], + parameter_map={ + "tags": {"location": "query", "openapi_name": "tags"}, + }, + ) + + request = director.build(route, {"tags": ["a", "b"]}, "https://example.com") + url = str(request.url) + assert "tags=a" in url + assert "tags=b" in url + + def test_explode_false_with_integers(self, director): + """explode=false works with non-string values.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="ids", + location="query", + required=True, + schema={"type": "array", "items": {"type": "integer"}}, + explode=False, + ) + ], + parameter_map={ + "ids": {"location": "query", "openapi_name": "ids"}, + }, + ) + + request = director.build(route, {"ids": [1, 2, 3]}, "https://example.com") + url = str(request.url) + assert "ids=1%2C2%2C3" in url or "ids=1,2,3" in url + assert url.count("ids=") == 1 + + def test_scalar_query_param_unaffected_by_explode(self, director): + """Non-list values pass through regardless of explode setting.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="get_item", + parameters=[ + ParameterInfo( + name="name", + location="query", + required=True, + schema={"type": "string"}, + explode=False, + ) + ], + parameter_map={ + "name": {"location": "query", "openapi_name": "name"}, + }, + ) + + request = director.build(route, {"name": "foo"}, "https://example.com") + assert "name=foo" in str(request.url) + + def test_pipe_delimited_explode_false(self, director): + """style=pipeDelimited, explode=false sends ids=1|2|3.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="ids", + location="query", + required=True, + schema={"type": "array", "items": {"type": "string"}}, + explode=False, + style="pipeDelimited", + ) + ], + parameter_map={ + "ids": {"location": "query", "openapi_name": "ids"}, + }, + ) + + request = director.build(route, {"ids": ["1", "2", "3"]}, "https://example.com") + url = str(request.url) + assert "ids=1%7C2%7C3" in url or "ids=1|2|3" in url + assert url.count("ids=") == 1 + + def test_space_delimited_explode_false(self, director): + """style=spaceDelimited, explode=false sends ids=1%202%203.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="ids", + location="query", + required=True, + schema={"type": "array", "items": {"type": "string"}}, + explode=False, + style="spaceDelimited", + ) + ], + parameter_map={ + "ids": {"location": "query", "openapi_name": "ids"}, + }, + ) + + request = director.build(route, {"ids": ["1", "2", "3"]}, "https://example.com") + url = str(request.url) + assert "ids=1+2+3" in url or "ids=1%202%203" in url + assert url.count("ids=") == 1 + + def test_explode_false_booleans_lowercased(self, director): + """Booleans serialize as true/false, not True/False.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="flags", + location="query", + required=True, + schema={"type": "array", "items": {"type": "boolean"}}, + explode=False, + ) + ], + parameter_map={ + "flags": {"location": "query", "openapi_name": "flags"}, + }, + ) + + request = director.build(route, {"flags": [True, False]}, "https://example.com") + url = str(request.url) + assert "true" in url and "false" in url + assert "True" not in url and "False" not in url + + def test_explode_false_empty_list_omitted(self, director): + """Empty list with explode=false omits the parameter entirely.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="ids", + location="query", + required=False, + schema={"type": "array", "items": {"type": "string"}}, + explode=False, + ) + ], + parameter_map={ + "ids": {"location": "query", "openapi_name": "ids"}, + }, + ) + + request = director.build(route, {"ids": []}, "https://example.com") + assert "ids" not in str(request.url) + + def test_explode_false_dict_value(self, director): + """style=form, explode=false on objects serializes as key,value pairs.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="color", + location="query", + required=True, + schema={ + "type": "object", + "properties": { + "R": {"type": "integer"}, + "G": {"type": "integer"}, + "B": {"type": "integer"}, + }, + }, + explode=False, + style="form", + ) + ], + parameter_map={ + "color": {"location": "query", "openapi_name": "color"}, + }, + ) + + request = director.build( + route, + {"color": {"R": 100, "G": 200, "B": 150}}, + "https://example.com", + ) + url = str(request.url) + assert "color=R" in url + assert url.count("color=") == 1 + # Should contain alternating key,value pairs + assert "100" in url and "200" in url and "150" in url + + def test_explode_true_dict_expands_to_separate_params(self, director): + """style=form, explode=true on objects expands each property as a query param.""" + route = HTTPRoute( + path="/test", + method="GET", + operation_id="test_endpoint", + parameters=[ + ParameterInfo( + name="data", + location="query", + required=True, + schema={ + "type": "object", + "properties": { + "myAttribute": {"type": "boolean"}, + }, + }, + explode=True, + ) + ], + parameter_map={ + "data": {"location": "query", "openapi_name": "data"}, + }, + ) + + request = director.build( + route, {"data": {"myAttribute": True}}, "https://example.com" + ) + url = str(request.url) + # Should expand to myAttribute=true (not data={'myAttribute': True}) + assert "myAttribute=true" in url + assert "data=" not in url + + def test_explode_default_dict_expands_to_separate_params(self, director): + """Default explode (None → true) on objects expands properties.""" + route = HTTPRoute( + path="/test", + method="GET", + operation_id="test_endpoint", + parameters=[ + ParameterInfo( + name="filter", + location="query", + required=True, + schema={ + "type": "object", + "properties": { + "category": {"type": "string"}, + "active": {"type": "boolean"}, + }, + }, + # explode defaults to None → treated as true + ) + ], + parameter_map={ + "filter": {"location": "query", "openapi_name": "filter"}, + }, + ) + + request = director.build( + route, + {"filter": {"category": "electronics", "active": False}}, + "https://example.com", + ) + url = str(request.url) + assert "category=electronics" in url + assert "active=false" in url + assert "filter=" not in url + + def test_explode_true_empty_dict_omitted(self, director): + """Empty dict with explode=true omits the parameter.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="filter", + location="query", + required=False, + schema={"type": "object"}, + explode=True, + ) + ], + parameter_map={ + "filter": {"location": "query", "openapi_name": "filter"}, + }, + ) + + request = director.build(route, {"filter": {}}, "https://example.com") + assert "filter" not in str(request.url) + + def test_explode_false_empty_dict_omitted(self, director): + """Empty dict with explode=false omits the parameter.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="filter", + location="query", + required=False, + schema={"type": "object"}, + explode=False, + ) + ], + parameter_map={ + "filter": {"location": "query", "openapi_name": "filter"}, + }, + ) + + request = director.build(route, {"filter": {}}, "https://example.com") + assert "filter" not in str(request.url) + + class TestRequestDirectorIntegration: """Test RequestDirector with real parsed routes.""" @@ -460,3 +1025,130 @@ class TestRequestDirectorIntegration: assert request.method == "GET" assert str(request.url).startswith("https://api.example.com/search") + + +class TestPathTraversalPrevention: + """Test that path parameter values are URL-encoded to prevent SSRF/path traversal.""" + + @pytest.fixture + def director(self, basic_openapi_30_spec): + spec = SchemaPath.from_dict(basic_openapi_30_spec) + return RequestDirector(spec) + + @pytest.fixture + def path_route(self): + return HTTPRoute( + path="/api/v1/users/{id}/profile", + method="GET", + operation_id="get_user_profile", + parameters=[ + ParameterInfo( + name="id", + location="path", + required=True, + schema={"type": "string"}, + ) + ], + flat_param_schema={ + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + parameter_map={"id": {"location": "path", "openapi_name": "id"}}, + ) + + @pytest.mark.parametrize( + "malicious_id", + [ + "../../../admin/delete-all?", + "../../secret", + "../../../etc/passwd", + "foo/../../../admin", + "..%2F..%2Fadmin", + "..%2f..%2fadmin", + ], + ) + def test_path_traversal_encoded(self, director, path_route, malicious_id: str): + request = director.build( + path_route, {"id": malicious_id}, "https://api.example.com" + ) + url = str(request.url) + assert "/admin" not in url + assert "/secret" not in url + assert "/etc/passwd" not in url + assert url.startswith("https://api.example.com/api/v1/users/") + + def test_slash_in_param_is_encoded(self, director, path_route): + request = director.build(path_route, {"id": "a/b"}, "https://api.example.com") + url = str(request.url) + assert "/a/b/" not in url + assert "a%2Fb" in url + + def test_dot_dot_slash_is_encoded(self, director, path_route): + request = director.build( + path_route, {"id": "../admin"}, "https://api.example.com" + ) + url = str(request.url) + assert "%2E%2E%2Fadmin" in url or "%2e%2e%2fadmin" in url + assert url.startswith("https://api.example.com/api/v1/users/") + + def test_question_mark_encoded(self, director, path_route): + request = director.build( + path_route, {"id": "foo?bar=baz"}, "https://api.example.com" + ) + url = str(request.url) + assert "foo%3Fbar%3Dbaz" in url or "foo%3fbar%3dbaz" in url + + def test_hash_encoded(self, director, path_route): + request = director.build( + path_route, {"id": "foo#fragment"}, "https://api.example.com" + ) + url = str(request.url) + assert "foo%23fragment" in url + + def test_normal_values_still_work(self, director, path_route): + request = director.build( + path_route, {"id": "user-123"}, "https://api.example.com" + ) + assert ( + str(request.url) == "https://api.example.com/api/v1/users/user-123/profile" + ) + + def test_dotted_values_encode_dots(self, director, path_route): + """Dots are encoded to prevent path normalization by urljoin.""" + request = director.build( + path_route, {"id": "v1.2.3"}, "https://api.example.com" + ) + url = str(request.url) + assert "v1%2E2%2E3" in url + assert url.startswith("https://api.example.com/api/v1/users/") + + def test_numeric_values_still_work(self, director, path_route): + request = director.build(path_route, {"id": 42}, "https://api.example.com") + assert str(request.url) == "https://api.example.com/api/v1/users/42/profile" + + def test_bare_single_dot_encoded(self, director, path_route): + """Bare '.' must be encoded so urljoin doesn't normalize it away.""" + request = director.build(path_route, {"id": "."}, "https://api.example.com") + url = str(request.url) + assert "%2E" in url + assert url.startswith("https://api.example.com/api/v1/users/") + + def test_bare_dotdot_encoded(self, director, path_route): + """Bare '..' must be encoded so urljoin doesn't resolve it as traversal.""" + request = director.build(path_route, {"id": ".."}, "https://api.example.com") + url = str(request.url) + assert "%2E%2E" in url or "%2e%2e" in url + assert url.startswith("https://api.example.com/api/v1/users/") + + def test_double_encoded_traversal(self, director, path_route): + request = director.build( + path_route, + {"id": "..%2F..%2Fadmin"}, + "https://api.example.com", + ) + url = str(request.url) + decoded = unquote(unquote(url)) + # Verify traversal didn't escape the users/ prefix + assert decoded.startswith("https://api.example.com/api/v1/users/") + assert url.startswith("https://api.example.com/api/v1/users/") diff --git a/tests/utilities/test_async_utils.py b/tests/utilities/test_async_utils.py new file mode 100644 index 000000000..03177007d --- /dev/null +++ b/tests/utilities/test_async_utils.py @@ -0,0 +1,110 @@ +"""Tests for fastmcp.utilities.async_utils.""" + +import functools + +import pytest + +from fastmcp import Client, FastMCP +from fastmcp.prompts import prompt +from fastmcp.resources import resource +from fastmcp.tools import tool +from fastmcp.utilities.async_utils import is_coroutine_function + + +async def _async_fn(x: int) -> int: + return x + + +def _sync_fn(x: int) -> int: + return x + + +class TestIsCoroutineFunction: + def test_plain_async(self) -> None: + assert is_coroutine_function(_async_fn) is True + + def test_plain_sync(self) -> None: + assert is_coroutine_function(_sync_fn) is False + + def test_partial_async(self) -> None: + p = functools.partial(_async_fn, x=1) + assert is_coroutine_function(p) is True + + def test_partial_sync(self) -> None: + p = functools.partial(_sync_fn, x=1) + assert is_coroutine_function(p) is False + + def test_nested_partial_async(self) -> None: + p = functools.partial(functools.partial(_async_fn, x=1)) + assert is_coroutine_function(p) is True + + def test_nested_partial_sync(self) -> None: + p = functools.partial(functools.partial(_sync_fn, x=1)) + assert is_coroutine_function(p) is False + + def test_lambda(self) -> None: + assert is_coroutine_function(lambda: None) is False + + def test_non_callable(self) -> None: + assert is_coroutine_function(42) is False + + +class TestAsyncPartialIntegration: + async def test_async_partial_tool_runs(self) -> None: + async def greet(greeting: str, name: str) -> str: + return f"{greeting}, {name}!" + + greet_tool = tool(name="greet")(functools.partial(greet, "Hello")) + + mcp = FastMCP() + mcp.add_tool(greet_tool) + + async with Client(mcp) as client: + result = await client.call_tool("greet", {"name": "world"}) + assert result.content[0].text == "Hello, world!" + + async def test_async_partial_resource_reads(self) -> None: + async def make_greeting(greeting: str) -> str: + return f"{greeting}, resource!" + + greet_resource = resource("test://greet")( + functools.partial(make_greeting, "Hi") + ) + + mcp = FastMCP() + mcp.add_resource(greet_resource) + + async with Client(mcp) as client: + result = await client.read_resource("test://greet") + assert result[0].text == "Hi, resource!" + + async def test_async_partial_prompt_renders(self) -> None: + async def make_prompt(prefix: str) -> str: + return f"{prefix}: prompt content" + + note_prompt = prompt(name="note")(functools.partial(make_prompt, "Note")) + + mcp = FastMCP() + mcp.add_prompt(note_prompt) + + async with Client(mcp) as client: + result = await client.get_prompt("note") + assert "Note: prompt content" in result.messages[0].content.text + + async def test_async_partial_with_task_true_does_not_raise(self) -> None: + async def slow_task(prefix: str, x: int) -> str: + return f"{prefix}-{x}" + + slow_tool = tool(name="slow", task=True)(functools.partial(slow_task, "ok")) + + mcp = FastMCP() + mcp.add_tool(slow_tool) + + async def test_sync_partial_with_task_true_raises(self) -> None: + def sync_task(prefix: str, x: int) -> str: + return f"{prefix}-{x}" + + mcp = FastMCP() + with pytest.raises(ValueError, match="sync function"): + decorated = tool(name="slow", task=True)(functools.partial(sync_task, "ok")) + mcp.add_tool(decorated) diff --git a/tests/utilities/test_components.py b/tests/utilities/test_components.py index d359ca467..cea67ea5f 100644 --- a/tests/utilities/test_components.py +++ b/tests/utilities/test_components.py @@ -5,14 +5,15 @@ import warnings import pytest from pydantic import ValidationError -from fastmcp.prompts.prompt import Prompt -from fastmcp.resources.resource import Resource +from fastmcp.prompts.base import Prompt +from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate -from fastmcp.tools.tool import Tool +from fastmcp.tools.base import Tool from fastmcp.utilities.components import ( FastMCPComponent, FastMCPMeta, _convert_set_default_none, + get_fastmcp_metadata, ) @@ -171,19 +172,19 @@ class TestFastMCPComponent: """Test that tags are deduplicated when passed as a sequence.""" component = FastMCPComponent( name="test", - tags=["tag1", "tag2", "tag1", "tag2"], # type: ignore[arg-type] + tags=["tag1", "tag2", "tag1", "tag2"], # type: ignore[arg-type] # ty:ignore[invalid-argument-type] ) assert component.tags == {"tag1", "tag2"} def test_validation_error_for_invalid_data(self): """Test that validation errors are raised for invalid data.""" with pytest.raises(ValidationError): - FastMCPComponent() # type: ignore[call-arg] + FastMCPComponent() # type: ignore[call-arg] # ty:ignore[missing-argument] def test_extra_fields_forbidden(self): """Test that extra fields are not allowed.""" with pytest.raises(ValidationError) as exc_info: - FastMCPComponent(name="test", unknown_field="value") # type: ignore[call-arg] # Intentionally passing invalid field for test + FastMCPComponent(name="test", unknown_field="value") # type: ignore[call-arg] # Intentionally passing invalid field for test # ty:ignore[unknown-argument] assert "Extra inputs are not permitted" in str(exc_info.value) @@ -236,8 +237,10 @@ class TestKeyPrefix: class NoPrefix(FastMCPComponent): pass - assert len(w) == 1 - assert "NoPrefix does not define KEY_PREFIX" in str(w[0].message) + key_prefix_warnings = [ + x for x in w if "does not define KEY_PREFIX" in str(x.message) + ] + assert len(key_prefix_warnings) == 1 def test_no_warning_when_key_prefix_defined(self): """Test that subclassing with KEY_PREFIX does not emit a warning.""" @@ -247,10 +250,32 @@ class TestKeyPrefix: class WithPrefix(FastMCPComponent): KEY_PREFIX = "custom" - assert len(w) == 0 + key_prefix_warnings = [ + x for x in w if "does not define KEY_PREFIX" in str(x.message) + ] + assert len(key_prefix_warnings) == 0 assert WithPrefix.make_key("test") == "custom:test" +class TestGetFastMCPMetadata: + """Tests for get_fastmcp_metadata helper.""" + + def test_returns_fastmcp_namespace_when_dict(self): + meta = {"fastmcp": {"tags": ["a"]}, "_fastmcp": {"tags": ["b"]}} + + assert get_fastmcp_metadata(meta) == {"tags": ["a"]} + + def test_falls_back_to_legacy_namespace_when_dict(self): + meta = {"fastmcp": "invalid", "_fastmcp": {"tags": ["legacy"]}} + + assert get_fastmcp_metadata(meta) == {"tags": ["legacy"]} + + def test_ignores_non_dict_metadata(self): + assert get_fastmcp_metadata({"fastmcp": "invalid"}) == {} + assert get_fastmcp_metadata({"fastmcp": ["invalid"]}) == {} + assert get_fastmcp_metadata({"_fastmcp": "invalid"}) == {} + + class TestComponentEnableDisable: """Tests for the enable/disable methods raising NotImplementedError.""" diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 3fce2a7ac..8802d69a4 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -1,5 +1,10 @@ +from unittest.mock import patch + +from jsonref import replace_refs + from fastmcp.utilities.json_schema import ( _prune_param, + _strip_remote_refs, compress_schema, dereference_refs, resolve_root_ref, @@ -192,6 +197,67 @@ class TestDereferenceRefs: assert country["default"] == "US" assert "$defs" not in result + def test_strips_discriminator_mapping_after_inlining(self): + """Discriminator.mapping refs dangle after $defs are inlined (#3679).""" + schema = { + "$defs": { + "IdentifyPerson": { + "type": "object", + "properties": { + "action": {"const": "identify", "type": "string"}, + "name": {"type": "string"}, + }, + "required": ["action", "name"], + }, + "PersonDelete": { + "type": "object", + "properties": { + "action": {"const": "delete", "type": "string"}, + }, + "required": ["action"], + }, + }, + "anyOf": [ + {"$ref": "#/$defs/IdentifyPerson"}, + {"$ref": "#/$defs/PersonDelete"}, + ], + "discriminator": { + "mapping": { + "identify": "#/$defs/IdentifyPerson", + "delete": "#/$defs/PersonDelete", + }, + "propertyName": "action", + }, + } + result = dereference_refs(schema) + + assert "$defs" not in result + assert "discriminator" not in result + # The anyOf variants should be inlined with their const values intact + assert len(result["anyOf"]) == 2 + actions = {v["properties"]["action"]["const"] for v in result["anyOf"]} + assert actions == {"identify", "delete"} + + def test_preserves_property_named_discriminator(self): + """A field *named* 'discriminator' inside properties must survive.""" + schema = { + "$defs": { + "Inner": { + "type": "object", + "properties": { + "discriminator": {"type": "string"}, + }, + }, + }, + "properties": { + "item": {"$ref": "#/$defs/Inner"}, + }, + } + result = dereference_refs(schema) + + assert "$defs" not in result + assert "discriminator" in result["properties"]["item"]["properties"] + class TestCompressSchema: """Tests for the compress_schema function.""" @@ -355,6 +421,35 @@ class TestCompressSchema: assert "title" not in compressed["properties"]["name"] assert "title" not in compressed["properties"]["title"] + def test_title_pruning_preserves_title_property_when_type_property_exists(self): + """Regression test for #3576: properties dict containing both 'title' and + 'type' as parameter names caused the heuristic to treat 'title' as schema + metadata and strip the entire property definition.""" + schema = { + "type": "object", + "properties": { + "dashboard_id": {"type": "string", "title": "Dashboard Id"}, + "title": {"type": "string", "title": "Title"}, + "type": {"type": "string", "title": "Type", "default": "vis"}, + }, + "required": ["dashboard_id", "title"], + } + + compressed = compress_schema(schema, prune_titles=True) + + # All three properties must survive + assert "dashboard_id" in compressed["properties"] + assert "title" in compressed["properties"] + assert "type" in compressed["properties"] + + # 'title' is still required + assert "title" in compressed["required"] + + # But metadata title strings inside each property schema are removed + assert "title" not in compressed["properties"]["dashboard_id"] + assert "title" not in compressed["properties"]["title"] + assert "title" not in compressed["properties"]["type"] + def test_title_pruning_with_nested_properties(self): """Test that nested property structures are handled correctly.""" schema = { @@ -628,3 +723,126 @@ class TestResolveRootRef: # Should return original schema unchanged assert result is schema + + +class TestStripRemoteRefs: + """Tests for _strip_remote_refs which prevents SSRF/LFI via $ref.""" + + def test_preserves_local_ref(self): + schema = {"$ref": "#/$defs/Foo"} + assert _strip_remote_refs(schema) == {"$ref": "#/$defs/Foo"} + + def test_strips_http_ref(self): + schema = {"$ref": "http://evil.com/schema.json"} + assert _strip_remote_refs(schema) == {} + + def test_strips_https_ref(self): + schema = {"$ref": "https://evil.com/schema.json"} + assert _strip_remote_refs(schema) == {} + + def test_strips_file_ref(self): + schema = {"$ref": "file:///etc/passwd"} + assert _strip_remote_refs(schema) == {} + + def test_preserves_siblings_when_stripping(self): + schema = { + "$ref": "http://evil.com/schema.json", + "description": "keep me", + "default": 42, + } + result = _strip_remote_refs(schema) + assert result == {"description": "keep me", "default": 42} + + def test_strips_nested_remote_refs(self): + schema = { + "properties": { + "safe": {"$ref": "#/$defs/Safe"}, + "evil": {"$ref": "http://169.254.169.254/latest/meta-data/"}, + } + } + result = _strip_remote_refs(schema) + assert result["properties"]["safe"] == {"$ref": "#/$defs/Safe"} + assert "$ref" not in result["properties"]["evil"] + + def test_strips_remote_refs_in_lists(self): + schema = { + "anyOf": [ + {"$ref": "#/$defs/Good"}, + {"$ref": "file:///etc/credentials.json"}, + ] + } + result = _strip_remote_refs(schema) + assert result["anyOf"][0] == {"$ref": "#/$defs/Good"} + assert "$ref" not in result["anyOf"][1] + + def test_deep_nesting(self): + schema = { + "properties": { + "a": { + "type": "object", + "properties": {"b": {"$ref": "https://internal-service/secret"}}, + } + } + } + result = _strip_remote_refs(schema) + assert "$ref" not in result["properties"]["a"]["properties"]["b"] + + +class TestDereferenceRefsRemoteRefSafety: + """Verify dereference_refs never fetches remote URIs.""" + + def test_http_ref_not_fetched(self): + schema = { + "type": "object", + "properties": { + "name": {"$ref": "http://evil.com/schema.json"}, + }, + } + with patch( + "fastmcp.utilities.json_schema.replace_refs", wraps=replace_refs + ) as mock: + result = dereference_refs(schema) + # The remote $ref should have been stripped before replace_refs + if mock.called: + call_schema = mock.call_args[0][0] + assert "$ref" not in call_schema.get("properties", {}).get("name", {}) + # Result should not contain the remote $ref + assert "$ref" not in result.get("properties", {}).get("name", {}) + + def test_file_ref_not_fetched(self): + schema = { + "type": "object", + "properties": { + "secret": {"$ref": "file:///etc/passwd"}, + }, + } + result = dereference_refs(schema) + assert "$ref" not in result.get("properties", {}).get("secret", {}) + + def test_cloud_metadata_ref_not_fetched(self): + schema = { + "type": "object", + "properties": { + "creds": { + "$ref": "http://169.254.169.254/latest/meta-data/iam/security-credentials/" + }, + }, + } + result = dereference_refs(schema) + assert "$ref" not in result.get("properties", {}).get("creds", {}) + + def test_local_refs_still_resolved(self): + schema = { + "$defs": {"Status": {"type": "string", "enum": ["a", "b"]}}, + "type": "object", + "properties": { + "status": {"$ref": "#/$defs/Status"}, + "evil": {"$ref": "https://evil.com/inject"}, + }, + } + result = dereference_refs(schema) + # Local ref should be resolved + assert result["properties"]["status"] == {"type": "string", "enum": ["a", "b"]} + # Remote ref should be stripped + assert "$ref" not in result["properties"]["evil"] + assert "$defs" not in result diff --git a/tests/utilities/test_skills.py b/tests/utilities/test_skills.py index 28a3f4fd1..46170529d 100644 --- a/tests/utilities/test_skills.py +++ b/tests/utilities/test_skills.py @@ -267,3 +267,24 @@ class TestSyncSkills: assert isinstance(path, Path) assert path.exists() assert (path / "SKILL.md").exists() + + +class TestPathTraversal: + @pytest.mark.parametrize( + "malicious_name", + [ + "../escape", + "../../root", + "../../../etc/passwd", + "foo/../../escape", + ], + ) + async def test_malicious_skill_name_raises( + self, skills_server: FastMCP, tmp_path: Path, malicious_name: str + ): + target = tmp_path / "downloaded" + target.mkdir() + + async with Client(skills_server) as client: + with pytest.raises(ValueError, match="would escape the target directory"): + await download_skill(client, malicious_name, target) diff --git a/tests/utilities/test_token_cache.py b/tests/utilities/test_token_cache.py new file mode 100644 index 000000000..93bbae40b --- /dev/null +++ b/tests/utilities/test_token_cache.py @@ -0,0 +1,226 @@ +"""Tests for the shared TokenCache utility.""" + +import time + +import pytest + +from fastmcp.server.auth.auth import AccessToken +from fastmcp.utilities.token_cache import TokenCache + + +def _make_token( + *, + token: str = "tok", + client_id: str = "client-1", + scopes: list[str] | None = None, + expires_at: int | None = None, +) -> AccessToken: + return AccessToken( + token=token, + client_id=client_id, + scopes=scopes or [], + expires_at=expires_at, + ) + + +class TestTokenCacheDisabled: + """Verify behaviour when caching is turned off.""" + + @pytest.mark.parametrize( + "ttl, max_size", + [ + (None, None), + (0, 100), + (300, 0), + ], + ) + def test_disabled_configurations(self, ttl: int | None, max_size: int | None): + cache = TokenCache(ttl_seconds=ttl, max_size=max_size) + assert not cache.enabled + + def test_negative_ttl_raises(self): + with pytest.raises(ValueError, match="cache_ttl_seconds must be non-negative"): + TokenCache(ttl_seconds=-1) + + def test_negative_max_size_raises(self): + with pytest.raises(ValueError, match="max_cache_size must be non-negative"): + TokenCache(max_size=-1) + + def test_get_returns_miss_when_disabled(self): + cache = TokenCache(ttl_seconds=0) + cache.set("tok", _make_token()) + hit, result = cache.get("tok") + assert not hit + assert result is None + + def test_set_is_noop_when_disabled(self): + cache = TokenCache(ttl_seconds=0) + cache.set("tok", _make_token()) + assert len(cache._entries) == 0 + + +class TestTokenCacheEnabled: + """Core get/set behaviour with caching on.""" + + @pytest.fixture + def cache(self) -> TokenCache: + return TokenCache(ttl_seconds=300, max_size=100) + + def test_enabled(self, cache: TokenCache): + assert cache.enabled + + def test_set_and_get(self, cache: TokenCache): + access = _make_token(client_id="user-1") + cache.set("tok-1", access) + + hit, result = cache.get("tok-1") + assert hit + assert result is not None + assert result.client_id == "user-1" + + def test_miss_for_unknown_token(self, cache: TokenCache): + hit, result = cache.get("unknown") + assert not hit + assert result is None + + def test_different_tokens_cached_separately(self, cache: TokenCache): + cache.set("tok-a", _make_token(client_id="a")) + cache.set("tok-b", _make_token(client_id="b")) + + _, a = cache.get("tok-a") + _, b = cache.get("tok-b") + assert a is not None and a.client_id == "a" + assert b is not None and b.client_id == "b" + + +class TestTokenCacheDefensiveCopy: + """Mutating a returned token must not affect the cached value.""" + + def test_get_returns_deep_copy(self): + cache = TokenCache(ttl_seconds=300, max_size=100) + access = _make_token(client_id="orig") + access.claims = {"key": "original"} + cache.set("tok", access) + + _, first = cache.get("tok") + assert first is not None + first.claims["key"] = "mutated" + first.scopes.append("admin") + + _, second = cache.get("tok") + assert second is not None + assert second.claims["key"] == "original" + assert "admin" not in second.scopes + + def test_mutating_source_does_not_affect_cache(self): + cache = TokenCache(ttl_seconds=300, max_size=100) + access = _make_token(client_id="orig") + access.claims = {"key": "original"} + cache.set("tok", access) + + access.claims["key"] = "mutated" + + _, cached = cache.get("tok") + assert cached is not None + assert cached.claims["key"] == "original" + + +class TestTokenCacheTTL: + """Expiration and TTL behaviour.""" + + def test_expired_entry_is_evicted_on_get(self): + cache = TokenCache(ttl_seconds=300, max_size=100) + cache.set("tok", _make_token()) + + key = cache._hash_token("tok") + cache._entries[key].expires_at = time.time() - 1 + + hit, result = cache.get("tok") + assert not hit + assert result is None + assert key not in cache._entries + + def test_token_expires_at_caps_ttl(self): + cache = TokenCache(ttl_seconds=300, max_size=100) + short_exp = int(time.time()) + 30 + cache.set("tok", _make_token(expires_at=short_exp)) + + key = cache._hash_token("tok") + assert cache._entries[key].expires_at <= short_exp + + def test_ttl_used_when_no_token_expiry(self): + cache = TokenCache(ttl_seconds=60, max_size=100) + before = time.time() + cache.set("tok", _make_token(expires_at=None)) + after = time.time() + + key = cache._hash_token("tok") + entry = cache._entries[key] + assert before + 60 <= entry.expires_at <= after + 60 + + +class TestTokenCacheSizeLimit: + """Eviction and size-limit behaviour.""" + + def test_evicts_oldest_when_full(self): + cache = TokenCache(ttl_seconds=300, max_size=2) + cache.set("tok-0", _make_token(client_id="0")) + cache.set("tok-1", _make_token(client_id="1")) + cache.set("tok-2", _make_token(client_id="2")) + + assert len(cache._entries) == 2 + hit_0, _ = cache.get("tok-0") + assert not hit_0 + + hit_1, _ = cache.get("tok-1") + hit_2, _ = cache.get("tok-2") + assert hit_1 + assert hit_2 + + def test_cleanup_expired_before_eviction(self): + cache = TokenCache(ttl_seconds=300, max_size=2) + cache.set("tok-0", _make_token(client_id="0")) + cache.set("tok-1", _make_token(client_id="1")) + + key_0 = cache._hash_token("tok-0") + cache._entries[key_0].expires_at = time.time() - 1 + + cache.set("tok-2", _make_token(client_id="2")) + + assert len(cache._entries) == 2 + hit_1, _ = cache.get("tok-1") + hit_2, _ = cache.get("tok-2") + assert hit_1 + assert hit_2 + + def test_overwrite_does_not_evict(self): + """Overwriting an existing key should not evict another entry.""" + cache = TokenCache(ttl_seconds=300, max_size=2) + cache.set("tok-0", _make_token(client_id="0")) + cache.set("tok-1", _make_token(client_id="1")) + + # Overwrite tok-0 — should NOT evict tok-1 + cache.set("tok-0", _make_token(client_id="0-updated")) + + assert len(cache._entries) == 2 + hit_0, result_0 = cache.get("tok-0") + hit_1, _ = cache.get("tok-1") + assert hit_0 + assert hit_1 + assert result_0 is not None + assert result_0.client_id == "0-updated" + + +class TestTokenCacheHashing: + """SHA-256 key hashing.""" + + def test_consistent_hashing(self): + assert TokenCache._hash_token("abc") == TokenCache._hash_token("abc") + + def test_different_tokens_different_hashes(self): + assert TokenCache._hash_token("abc") != TokenCache._hash_token("xyz") + + def test_hash_is_64_hex_chars(self): + h = TokenCache._hash_token("anything") + assert len(h) == 64 + int(h, 16) # must be valid hex diff --git a/tests/utilities/test_types.py b/tests/utilities/test_types.py index 7901caff9..1f349ebdb 100644 --- a/tests/utilities/test_types.py +++ b/tests/utilities/test_types.py @@ -671,3 +671,21 @@ class TestAnnotationStringDescriptions: # Should keep the Field description assert schema["properties"]["name"]["description"] == "Field desc" + + def test_kwonly_defaults_preserved_when_annotations_are_processed(self): + """Keyword-only defaults should survive function cloning during annotation processing.""" + + def func(*, limit: Annotated[int, "Maximum number of results"] = 5) -> int: + return limit + + adapter = get_cached_typeadapter(func) + schema = adapter.json_schema() + + assert "required" not in schema + assert schema["properties"]["limit"]["default"] == 5 + assert ( + schema["properties"]["limit"]["description"] == "Maximum number of results" + ) + + validated = adapter.validate_python({}) + assert validated == 5 diff --git a/uv.lock b/uv.lock index 4bf824746..c29b8f44b 100644 --- a/uv.lock +++ b/uv.lock @@ -2,8 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version >= '3.11' and python_full_version < '3.13'", + "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'", ] @@ -39,7 +41,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.84.0" +version = "0.86.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -51,23 +53,23 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" }, + { url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" }, ] [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" 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/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { 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" }, ] [[package]] @@ -90,41 +92,41 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "authlib" -version = "1.6.8" +version = "1.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/6c/c88eac87468c607f88bc24df1f3b31445ee6fc9ba123b09e666adf687cd9/authlib-1.6.8.tar.gz", hash = "sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb", size = 165074, upload-time = "2026-02-14T04:02:17.941Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/73/f7084bf12755113cd535ae586782ff3a6e710bfbe6a0d13d1c2f81ffbbfa/authlib-1.6.8-py2.py3-none-any.whl", hash = "sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888", size = 244116, upload-time = "2026-02-14T04:02:15.579Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] [[package]] name = "azure-core" -version = "1.38.1" +version = "1.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/9b/23893febea484ad8183112c9419b5eb904773adb871492b5fa8ff7b21e09/azure_core-1.38.1.tar.gz", hash = "sha256:9317db1d838e39877eb94a2240ce92fa607db68adf821817b723f0d679facbf6", size = 363323, upload-time = "2026-02-11T02:03:06.051Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/88/aaea2ad269ce70b446660371286272c1f6ba66541a7f6f635baf8b0db726/azure_core-1.38.1-py3-none-any.whl", hash = "sha256:69f08ee3d55136071b7100de5b198994fc1c5f89d2b91f2f43156d20fcf200a4", size = 217930, upload-time = "2026-02-11T02:03:07.548Z" }, + { 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" }, ] [[package]] name = "azure-identity" -version = "1.25.2" +version = "1.25.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core" }, @@ -133,9 +135,9 @@ dependencies = [ { name = "msal-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709, upload-time = "2026-02-11T01:55:42.323Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423, upload-time = "2026-02-11T01:55:44.245Z" }, + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, ] [[package]] @@ -167,11 +169,11 @@ wheels = [ [[package]] name = "cachetools" -version = "7.0.1" +version = "7.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" }, + { 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" }, ] [[package]] @@ -182,24 +184,34 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db wheels = [ { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, + { 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" }, ] [[package]] @@ -286,91 +298,107 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.4" +version = "3.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, + { 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" }, ] [[package]] @@ -405,115 +433,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.4" +version = "7.13.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, - { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, - { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, - { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, - { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, - { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, - { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, - { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, - { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, - { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, - { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, - { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, - { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, - { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, - { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, - { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, - { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, - { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, - { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, - { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, - { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, - { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, - { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, - { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, - { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, - { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, + { 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" }, ] [package.optional-dependencies] @@ -522,81 +550,76 @@ toml = [ ] [[package]] -name = "croniter" -version = "6.0.0" +name = "cronsim" +version = "2.7" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, - { name = "pytz" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/2f/44d1ae153a0e27be56be43465e5cb39b9650c781e001e7864389deb25090/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577", size = 64481, upload-time = "2024-12-17T17:17:47.32Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1a/02f105147f7f2e06ed4f734ff5a6439590bb275a53dd91fc73df6312298a/cronsim-2.7-py3-none-any.whl", hash = "sha256:1e1431fa08c51dc7f72e67e571c7c7a09af26420169b607badd4ca9677ffad1e", size = 14213, upload-time = "2025-10-21T16:38:20.431Z" }, ] [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, - { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, + { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, ] [[package]] name = "cyclopts" -version = "4.5.3" +version = "4.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -606,9 +629,9 @@ 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/a5/16/06e35c217334930ff7c476ce1c8e74ed786fa3ef6742e59a1458e2412290/cyclopts-4.5.3.tar.gz", hash = "sha256:35fa70971204c450d9668646a6ca372eb5fa3070fbe8dd51c5b4b31e65198f2d", size = 162437, upload-time = "2026-02-16T15:07:11.96Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/1f/d8bce383a90d8a6a11033327777afa4d4d611ec11869284adb6f48152906/cyclopts-4.5.3-py3-none-any.whl", hash = "sha256:50af3085bb15d4a6f2582dd383dad5e4ba6a0d4d4c64ee63326d881a752a6919", size = 200231, upload-time = "2026-02-16T15:07:13.045Z" }, + { 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" }, ] [[package]] @@ -710,16 +733,16 @@ wheels = [ [[package]] name = "fakeredis" -version = "2.34.0" +version = "2.34.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "redis" }, { name = "sortedcontainers" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/44/c403963727d707e03f49a417712b0a23e853d33ae50729679040b6cfe281/fakeredis-2.34.0.tar.gz", hash = "sha256:72bc51a7ab39bedf5004f0cf1b5206822619c1be8c2657fd878d1f4250256c57", size = 177156, upload-time = "2026-02-16T15:56:34.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/40/fd09efa66205eb32253d2b2ebc63537281384d2040f0a88bcd2289e120e4/fakeredis-2.34.1.tar.gz", hash = "sha256:4ff55606982972eecce3ab410e03d746c11fe5deda6381d913641fbd8865ea9b", size = 177315, upload-time = "2026-02-25T13:17:51.315Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/8e/af19c00753c432355f9b76cec3ab0842578de43ba575e82735b18c1b3ec9/fakeredis-2.34.0-py3-none-any.whl", hash = "sha256:bc45d362c6cc3a537f8287372d8ea532538dfbe7f5d635d0905d7b3464ec51d2", size = 122063, upload-time = "2026-02-16T15:56:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/49/b5/82f89307d0d769cd9bf46a54fb9136be08e4e57c5570ae421db4c9a2ba62/fakeredis-2.34.1-py3-none-any.whl", hash = "sha256:0107ec99d48913e7eec2a5e3e2403d1bd5f8aa6489d1a634571b975289c48f12", size = 122160, upload-time = "2026-02-25T13:17:49.701Z" }, ] [package.optional-dependencies] @@ -742,7 +765,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.129.0" +version = "0.135.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -751,9 +774,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, + { 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" }, ] [[package]] @@ -792,6 +815,7 @@ apps = [ ] azure = [ { name = "azure-identity" }, + { name = "pyjwt" }, ] code-mode = [ { name = "pydantic-monty" }, @@ -812,8 +836,9 @@ dev = [ { name = "fastapi" }, { name = "fastmcp", extra = ["anthropic", "apps", "azure", "code-mode", "gemini", "openai", "tasks"] }, { name = "inline-snapshot", extra = ["dirty-equals"] }, - { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { 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 = "loq" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-sdk" }, @@ -838,7 +863,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.40.0" }, + { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.48.0" }, { name = "authlib", specifier = ">=1.6.5" }, { name = "azure-identity", marker = "extra == 'azure'", specifier = ">=1.16.0" }, { name = "cyclopts", specifier = ">=4.0.0" }, @@ -853,11 +878,12 @@ requires-dist = [ { name = "opentelemetry-api", specifier = ">=1.20.0" }, { name = "packaging", specifier = ">=24.0" }, { name = "platformdirs", specifier = ">=4.0.0" }, - { name = "prefab-ui", marker = "extra == 'apps'", specifier = ">=0.6.0" }, + { name = "prefab-ui", marker = "extra == 'apps'", specifier = ">=0.18.0" }, { name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], specifier = ">=0.4.4,<0.5.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, - { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = ">=0.0.7" }, + { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = "==0.0.9" }, { name = "pydocket", marker = "extra == 'tasks'", specifier = ">=0.18.0" }, + { name = "pyjwt", marker = "extra == 'azure'", specifier = ">=2.12.0" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, @@ -895,21 +921,20 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.12.8" }, - { name = "ty", specifier = ">=0.0.20" }, + { name = "ty", specifier = ">=0.0.26" }, ] [[package]] name = "google-auth" -version = "2.48.0" +version = "2.49.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, - { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, + { 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" }, ] [package.optional-dependencies] @@ -919,7 +944,7 @@ requests = [ [[package]] name = "google-genai" -version = "1.65.0" +version = "1.69.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -933,21 +958,21 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/f9/cc1191c2540d6a4e24609a586c4ed45d2db57cfef47931c139ee70e5874a/google_genai-1.65.0.tar.gz", hash = "sha256:d470eb600af802d58a79c7f13342d9ea0d05d965007cae8f76c7adff3d7a4750", size = 497206, upload-time = "2026-02-26T00:20:33.824Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/3c/3fea4e7c91357c71782d7dcaad7a2577d636c90317e003386893c25bc62c/google_genai-1.65.0-py3-none-any.whl", hash = "sha256:68c025205856919bc03edb0155c11b4b833810b7ce17ad4b7a9eeba5158f6c44", size = 724429, upload-time = "2026-02-26T00:20:32.186Z" }, + { 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" }, ] [[package]] name = "googleapis-common-protos" -version = "1.72.0" +version = "1.73.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, + { 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" }, ] [[package]] @@ -1089,7 +1114,7 @@ wheels = [ [[package]] name = "inline-snapshot" -version = "0.32.0" +version = "0.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asttokens" }, @@ -1099,9 +1124,9 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/74/19294067d5b5b78144eab2aacec85b998c2d2ea6b3d24eefd9a90255d7aa/inline_snapshot-0.32.0.tar.gz", hash = "sha256:57fa3df325284d0d14def5dab9ac5da89e383f085bea9a7be51fdeab65e59ced", size = 2623331, upload-time = "2026-02-13T19:51:54.469Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/25/0e84a6322e5fdb1bf67870b2269151449f4894987b26c78718918dd64ea6/inline_snapshot-0.32.0-py3-none-any.whl", hash = "sha256:b522ae2c891f666e80213c5f9677ec6fd4a2a7d334ab9d6ce745675bec6a40f0", size = 84087, upload-time = "2026-02-13T19:51:52.604Z" }, + { 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" }, ] [package.optional-dependencies] @@ -1111,7 +1136,7 @@ dirty-equals = [ [[package]] name = "ipython" -version = "8.38.0" +version = "8.39.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11'", @@ -1129,35 +1154,60 @@ dependencies = [ { name = "traitlets", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/df/db59624f4c71b39717c423409950ac3f2c8b2ce4b0aac843112c7fb3f721/ipython-8.38.0-py3-none-any.whl", hash = "sha256:750162629d800ac65bb3b543a14e7a74b0e88063eac9b92124d4b2aa3f6d8e86", size = 831813, upload-time = "2026-01-05T10:59:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, ] [[package]] name = "ipython" -version = "9.10.0" +version = "9.10.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version >= '3.11' and python_full_version < '3.13'", + "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 = "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/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" } +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/3d/aa/898dec789a05731cd5a9f50605b7b44a72bd198fd0d4528e11fc610177cc/ipython-9.10.0-py3-none-any.whl", hash = "sha256:c6ab68cc23bba8c7e18e9b932797014cc61ea7fd6f19de180ab9ba73e65ee58d", size = 622774, upload-time = "2026-02-02T10:00:31.503Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", +] +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'" }, +] +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" } +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" }, ] [[package]] @@ -1186,14 +1236,14 @@ wheels = [ [[package]] name = "jaraco-context" -version = "6.1.0" +version = "6.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, ] [[package]] @@ -1352,17 +1402,16 @@ wheels = [ [[package]] name = "jsonschema-path" -version = "0.3.4" +version = "0.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pathable" }, { name = "pyyaml" }, { name = "referencing" }, - { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, + { 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" }, ] [[package]] @@ -1397,17 +1446,17 @@ wheels = [ [[package]] name = "loq" -version = "0.1.0a7" +version = "0.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/9f/27aacd5b1566ebbec1531ead263fa1f60899b8a7cd6aa1a710c34b05a5b8/loq-0.1.0a7.tar.gz", hash = "sha256:576f70f45d466accf6a4c1020e430f0fc008047eeae1889952bd981f9da12bf0", size = 63997, upload-time = "2026-01-22T04:42:14.476Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/81/a17733cc91f34192c7ebfa946df89e14385dc384952485fcacde759d0dc7/loq-0.1.0.tar.gz", hash = "sha256:2c0748bfbd0f36a7899d19bfa50af2609dbdd09351fc8951f7c4741ed4340c86", size = 73416, upload-time = "2026-03-15T15:54:52.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/e5/b55110b86951b184019bfe7290ee73115b932b3bb0d4428bbe20c68d2f31/loq-0.1.0a7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:47d40a9792397f8f100e7c2984d56227bd05c7f3d6817e8e26a7af88cbff0b26", size = 1336455, upload-time = "2026-01-22T04:42:23.812Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b9/519e83707c3819349e4497a4b30e9a814d155eb1317a25425cac73cb8045/loq-0.1.0a7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:178393fa15968082aafb54c24d2015eef7090b0668ca6715e3e3c390bdeeea46", size = 1259828, upload-time = "2026-01-22T04:42:21.951Z" }, - { url = "https://files.pythonhosted.org/packages/84/28/e9730ef5cf29baef96a015a514752d7142f48287ac7efc8bdd09794d2b28/loq-0.1.0a7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9527526e4cffb12c84207cd2a92ccfa90968e137908b4329d9f6e07ba0cb575", size = 1278762, upload-time = "2026-01-22T04:42:12.887Z" }, - { url = "https://files.pythonhosted.org/packages/90/48/be790e0542fa660ec84f863e0440570442e167fc5c65d8ad36bd93e5f315/loq-0.1.0a7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ed89b64ab1de6be9936d274b95f946a1b6f052493e1a1f1db3aac70852b3e0c", size = 1374495, upload-time = "2026-01-22T04:42:16.588Z" }, - { url = "https://files.pythonhosted.org/packages/29/8b/74e6e39ac302ff722acd2c154f5f6b3c28f750f914e859158b1333b2330e/loq-0.1.0a7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e54a96e61c9aafebfa873e5a897132e611b457892f149809380625d4fa9c1147", size = 1318652, upload-time = "2026-01-22T04:42:19.229Z" }, - { url = "https://files.pythonhosted.org/packages/57/b2/ae6bae30259715db7d97c7c7f929d5a210dd2a59b2564a020899fba8a7e5/loq-0.1.0a7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a21ab864964823678b8478fef83f42c2634662f736cf8b552de09736f52ca91f", size = 1440332, upload-time = "2026-01-22T04:42:17.897Z" }, - { url = "https://files.pythonhosted.org/packages/6b/48/11ed2d491b353cb7c159398a3017a64a5c424c85979a2eb805ded6763739/loq-0.1.0a7-py3-none-win_amd64.whl", hash = "sha256:a5d0f98e9613ffab868aacc1e1277ca3f7d39158c55aa3d0e534865591592da7", size = 1334210, upload-time = "2026-01-22T04:42:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/66fb5fb05c1381d3f7006ed0f3ea02f44e18f8908f616bb4a9c4a052388d/loq-0.1.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3609a7cb924fe3a91f4e2783302f103c4800fb77d3aa44f25751d7e59671b8c4", size = 1348859, upload-time = "2026-03-15T15:54:46.504Z" }, + { url = "https://files.pythonhosted.org/packages/ab/51/f0e99405c265dd700b6a325bd9c096a2fe6982ec251614470c10ff9ebd44/loq-0.1.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:06fbbb2d879bec68cbc3bf245ca9c0b3f1b34378520f088d43906ca4b005dcbc", size = 1264194, upload-time = "2026-03-15T15:54:42.6Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7c/9898a91f70ba757c07395bf4827953245ad1721b03f2a88750d4d789d342/loq-0.1.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98781f2da950c8a2f8ff1e136f7199abdfd1456b921253e97891eba1a6d8f7d7", size = 1285358, upload-time = "2026-03-15T15:54:44.035Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/57f6a11fa86d3e75f16283dbdb7925796bd430ec298407d2824e23de2740/loq-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f37096eb4e7deb8c11928aad914a472b0b74485a5dbebd16f17f223ef973e22", size = 1389403, upload-time = "2026-03-15T15:54:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1e/77fd1ac28d4bbb79a0006a7489aa527e6df00fef7668c47d7a69c46804e8/loq-0.1.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2af38e4e503986b930d036ec867720648a8dff468d6a938b6f3579024deace9c", size = 1334234, upload-time = "2026-03-15T15:54:45.162Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bd/eaea9d747ac43384c5e319886a23edb6b61007be6ca856f480110a57cb52/loq-0.1.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:97fbd077ed12627bc8a9897f863489cc0a535dee884a100c419c8039fd758b64", size = 1457837, upload-time = "2026-03-15T15:54:47.894Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/3477378f4a22c89f4b0268ffeb45738fc67d629067bbd6ad9a7de40e0ed0/loq-0.1.0-py3-none-win_amd64.whl", hash = "sha256:dcb0da5c60ba65d8978e91fd0e8116c920c79374dff78092cd1cc6a732fbaebf", size = 1368085, upload-time = "2026-03-15T15:54:49.468Z" }, ] [[package]] @@ -1553,16 +1602,16 @@ wheels = [ [[package]] name = "msal" -version = "1.34.0" +version = "1.35.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/aa/5a646093ac218e4a329391d5a31e5092a89db7d2ef1637a90b82cd0b6f94/msal-1.35.1.tar.gz", hash = "sha256:70cac18ab80a053bff86219ba64cfe3da1f307c74b009e2da57ef040eb1b5656", size = 165658, upload-time = "2026-03-04T23:38:51.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/96/86/16815fddf056ca998853c6dc525397edf0b43559bb4073a80d2bc7fe8009/msal-1.35.1-py3-none-any.whl", hash = "sha256:8f4e82f34b10c19e326ec69f44dc6b30171f2f7098f3720ea8a9f0c11832caa3", size = 119909, upload-time = "2026-03-04T23:38:50.452Z" }, ] [[package]] @@ -1579,7 +1628,7 @@ wheels = [ [[package]] name = "openai" -version = "2.21.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1591,9 +1640,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/e5/3d197a0947a166649f566706d7a4c8f7fe38f1fa7b24c9bcffe4c7591d44/openai-2.21.0.tar.gz", hash = "sha256:81b48ce4b8bbb2cc3af02047ceb19561f7b1dc0d4e52d1de7f02abfd15aa59b7", size = 644374, upload-time = "2026-02-14T00:12:01.577Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" }, + { 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" }, ] [[package]] @@ -1610,32 +1659,32 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, + { 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" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, + { 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" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -1646,48 +1695,48 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/48/b329fed2c610c2c32c9366d9dc597202c9d1e58e631c137ba15248d8850f/opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad", size = 24650, upload-time = "2025-12-11T13:32:41.429Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/a3/cc9b66575bd6597b98b886a2067eea2693408d2d5f39dad9ab7fc264f5f3/opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18", size = 19766, upload-time = "2025-12-11T13:32:21.027Z" }, + { 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" }, ] [[package]] name = "opentelemetry-proto" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, + { 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" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.39.1" +version = "1.40.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/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, + { 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" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.60b1" +version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, + { 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" }, ] [[package]] @@ -1710,24 +1759,24 @@ wheels = [ [[package]] name = "pathable" -version = "0.4.4" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, + { 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" }, ] [[package]] name = "pdbpp" -version = "0.12.0.post1" +version = "0.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fancycompleter" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/23/0bd339679059fb289e0752671b5eac63a472742d1de0035e13a27429e500/pdbpp-0.12.0.post1.tar.gz", hash = "sha256:cace9951d7414fe651141d240ab20e4509c8f97d20f7b142386d1bd1bb182d18", size = 75765, upload-time = "2026-01-19T10:56:55.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8d/dbc2c26f9c947e3d6df82d3ee7711fb52a92000177cea22fd3e428dc0429/pdbpp-0.12.1.tar.gz", hash = "sha256:932cc5963760105e33607f44a1a3b83d28096d7dbf055b77b527c5c70a1dafef", size = 77358, upload-time = "2026-02-23T14:23:44.993Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/f2/20fa391af27da4ef421832fb7d554748786efcc3b0da69a51e2ca66043d0/pdbpp-0.12.0.post1-py3-none-any.whl", hash = "sha256:8c62acd6adaa02f620d7d9cff689208a28019b6c05e6b894318f313e61fb1dc7", size = 30661, upload-time = "2026-01-19T10:56:53.638Z" }, + { url = "https://files.pythonhosted.org/packages/05/4e/0703722c46447fa03c9425386b3ef3e90254a7c1eb5da654c3c33b210317/pdbpp-0.12.1-py3-none-any.whl", hash = "sha256:3828809519439f468c9475c4c2cbb3899f2f5ba40e79c207d90eabe4c2373f5e", size = 30659, upload-time = "2026-02-23T14:23:43.66Z" }, ] [[package]] @@ -1744,11 +1793,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.2" +version = "4.9.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, + { 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" }, ] [[package]] @@ -1762,40 +1811,40 @@ wheels = [ [[package]] name = "prefab-ui" -version = "0.8.3" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cyclopts" }, { name = "pydantic" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/32/e7bc3db47cd93e2683241bd2a328b3136c44c4332a4277a2ac2f3f6beca4/prefab_ui-0.8.3.tar.gz", hash = "sha256:bfe1304f0fc457da764763232f533bff732160e8cc8ce12977ba48f8e7574152", size = 2819196, upload-time = "2026-02-27T14:44:10.52Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/23/cbc8decc176428a1f03b676fee4242f8a8d0ae7e76d5fc76ff96f13f13c1/prefab_ui-0.8.3-py3-none-any.whl", hash = "sha256:b1bac064958f08b20694e67b916ad8ab82de8db883578c31ba4437b521b425e1", size = 803888, upload-time = "2026-02-27T14:44:09.31Z" }, + { 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" }, ] [[package]] name = "prek" -version = "0.3.3" +version = "0.3.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/f1/7613dc8347a33e40fc5b79eec6bc7d458d8bbc339782333d8433b665f86f/prek-0.3.3.tar.gz", hash = "sha256:117bd46ebeb39def24298ce021ccc73edcf697b81856fcff36d762dd56093f6f", size = 343697, upload-time = "2026-02-15T13:33:28.723Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/8b/dce13d2a3065fd1e8ffce593a0e51c4a79c3cde9c9a15dc0acc8d9d1573d/prek-0.3.3-py3-none-linux_armv6l.whl", hash = "sha256:e8629cac4bdb131be8dc6e5a337f0f76073ad34a8305f3fe2bc1ab6201ede0a4", size = 4644636, upload-time = "2026-02-15T13:33:43.609Z" }, - { url = "https://files.pythonhosted.org/packages/01/30/06ab4dbe7ce02a8ce833e92deb1d9a8e85ae9d40e33d1959a2070b7494c6/prek-0.3.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4b9e819b9e4118e1e785047b1c8bd9aec7e4d836ed034cb58b7db5bcaaf49437", size = 4651410, upload-time = "2026-02-15T13:33:34.277Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fc/da3bc5cb38471e7192eda06b7a26b7c24ef83e82da2c1dbc145f2bf33640/prek-0.3.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bf29db3b5657c083eb8444c25aadeeec5167dc492e9019e188f87932f01ea50a", size = 4273163, upload-time = "2026-02-15T13:33:42.106Z" }, - { url = "https://files.pythonhosted.org/packages/b4/74/47839395091e2937beced81a5dd2f8ea9c8239c853da8611aaf78ee21a8b/prek-0.3.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ae09736149815b26e64a9d350ca05692bab32c2afdf2939114d3211aaad68a3e", size = 4631808, upload-time = "2026-02-15T13:33:20.076Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/3f5ef6f7c928c017cb63b029349d6bc03598ab7f6979d4a770ce02575f82/prek-0.3.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:856c2b55c51703c366bb4ce81c6a91102b70573a9fc8637db2ac61c66e4565f9", size = 4548959, upload-time = "2026-02-15T13:33:36.325Z" }, - { url = "https://files.pythonhosted.org/packages/b2/18/80002c4c4475f90ca025f27739a016927a0e5d905c60612fc95da1c56ab7/prek-0.3.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3acdf13a018f685beaff0a71d4b0d2ccbab4eaa1aced6d08fd471c1a654183eb", size = 4862256, upload-time = "2026-02-15T13:33:37.754Z" }, - { url = "https://files.pythonhosted.org/packages/c5/25/648bf084c2468fa7cfcdbbe9e59956bbb31b81f36e113bc9107d80af26a7/prek-0.3.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0f035667a8bd0a77b2bfa2b2e125da8cb1793949e9eeef0d8daab7f8ac8b57fe", size = 5404486, upload-time = "2026-02-15T13:33:39.239Z" }, - { url = "https://files.pythonhosted.org/packages/8b/43/261fb60a11712a327da345912bd8b338dc5a050199de800faafa278a6133/prek-0.3.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d09b2ad14332eede441d977de08eb57fb3f61226ed5fd2ceb7aadf5afcdb6794", size = 4887513, upload-time = "2026-02-15T13:33:40.702Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2c/581e757ee57ec6046b32e0ee25660fc734bc2622c319f57119c49c0cab58/prek-0.3.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c0c3ffac16e37a9daba43a7e8316778f5809b70254be138761a8b5b9ef0df28e", size = 4632336, upload-time = "2026-02-15T13:33:25.867Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d8/aa276ce5d11b77882da4102ca0cb7161095831105043ae7979bbfdcc3dc4/prek-0.3.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a3dc7720b580c07c0386e17af2486a5b4bc2f6cc57034a288a614dcbc4abe555", size = 4679370, upload-time = "2026-02-15T13:33:22.247Z" }, - { url = "https://files.pythonhosted.org/packages/70/19/9d4fa7bde428e58d9f48a74290c08736d42aeb5690dcdccc7a713e34a449/prek-0.3.3-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:60e0fa15da5020a03df2ee40268145ec5b88267ec2141a205317ad4df8c992d6", size = 4540316, upload-time = "2026-02-15T13:33:24.088Z" }, - { url = "https://files.pythonhosted.org/packages/25/b5/973cce29257e0b47b16cc9b4c162772ea01dbb7c080791ea0c068e106e05/prek-0.3.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:553515da9586d9624dc42db32b744fdb91cf62b053753037a0cadb3c2d8d82a2", size = 4724566, upload-time = "2026-02-15T13:33:29.832Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/ad8b2658895a8ed2b0bc630bf38686fe38b7ff2c619c58953a80e4de3048/prek-0.3.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:9512cf370e0d1496503463a4a65621480efb41b487841a9e9ff1661edf14b238", size = 4995072, upload-time = "2026-02-15T13:33:27.417Z" }, - { url = "https://files.pythonhosted.org/packages/fd/b7/0540c101c00882adb9d30319d22d8f879413598269ecc60235e41875efd4/prek-0.3.3-py3-none-win32.whl", hash = "sha256:b2b328c7c6dc14ccdc79785348589aa39850f47baff33d8f199f2dee80ff774c", size = 4293144, upload-time = "2026-02-15T13:33:46.013Z" }, - { url = "https://files.pythonhosted.org/packages/97/c7/e4f11da653093040efba2d835aa0995d78940aea30887287aeaebe34a545/prek-0.3.3-py3-none-win_amd64.whl", hash = "sha256:3d7d7acf7ca8db65ba0943c52326c898f84bab0b1c26a35c87e0d177f574ca5f", size = 4652761, upload-time = "2026-02-15T13:33:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/11/e4/d99dec54c6a5fb2763488bff6078166383169a93f3af27d2edae88379a39/prek-0.3.3-py3-none-win_arm64.whl", hash = "sha256:8aa87ee7628cd74482c0dd6537a3def1f162b25cd642d78b1b35dd3e81817f60", size = 4367520, upload-time = "2026-02-15T13:33:31.664Z" }, + { 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" }, ] [[package]] @@ -1821,17 +1870,17 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.5" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, - { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, - { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, - { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, - { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, + { 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" }, ] [[package]] @@ -1910,11 +1959,11 @@ redis = [ [[package]] name = "pyasn1" -version = "0.6.2" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, + { 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" }, ] [[package]] @@ -2078,93 +2127,93 @@ wheels = [ [[package]] name = "pydantic-monty" -version = "0.0.7" +version = "0.0.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/e3/0d8b2b025628477c839f894e632f5197872b19df0a86b2ec30fac3b5960a/pydantic_monty-0.0.7.tar.gz", hash = "sha256:2189ea1d7aadab2f95374733d692f51d1206379a4fc7ce18ab46895512e88f92", size = 684705, upload-time = "2026-02-19T14:12:47.235Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/11/d4024f543e15107c2e8081a294fb09d5ef57a32ca6fa12823d5887a3eaf1/pydantic_monty-0.0.9.tar.gz", hash = "sha256:9d3a85ce2e861b795b39d392410ac3620eb12261fdef6e1bc73edd6121d3c844", size = 888855, upload-time = "2026-03-28T13:18:27.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/25/4f923b64c6d52e2de788b21e20feb6a1acd0063419751a4cd843aa94c3de/pydantic_monty-0.0.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:8a8c133e83dcea584c17d4a18f4370d702f28c030b24be4aa033a8d094212b46", size = 6265462, upload-time = "2026-02-19T14:13:08.332Z" }, - { url = "https://files.pythonhosted.org/packages/ab/3f/6171caec0df775992b8aa4bbb862939e63fb49cab50db4c5329afa96590b/pydantic_monty-0.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:712c8db9fa80e6695aa5a829d5ed570f00bec071fe73cf32ed20d8ad7c21f83e", size = 6158093, upload-time = "2026-02-19T14:12:51.337Z" }, - { url = "https://files.pythonhosted.org/packages/4e/41/cd683e3546a32a489c1a43bc1d82653c4b62690525209bea23c9e9b6f986/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be373ba4acfcdd2047215f0675afb0cae2942ebd91f4d7f27a11ed224510432", size = 6060244, upload-time = "2026-02-19T14:14:36.326Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/326a61cb35b2a26e6a1f640359f91522c8ab4868d9d9fd81ec9fe846e29a/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98446b6cf32de3b1ad9e84b101f3db34a8799af35c95643068587d596f06b558", size = 6311667, upload-time = "2026-02-19T14:13:33.079Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3a/67d811f258a3ccff2ab2e2669792294ff47e875e0c305536adb19c68ee74/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b725086fb5c4b7205cf85b243c19596bb53cd24c35764f09a55454a23c1f0e37", size = 6861045, upload-time = "2026-02-19T14:13:06.35Z" }, - { url = "https://files.pythonhosted.org/packages/35/88/27cf345219b9504c58da2c53e2280fbac2b353d0c0afe7ab9d34d3ca0701/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94a43fcdfe607f25a0ba822f325610ca7018d73de4836c3eca27dac2a23fec7a", size = 6868099, upload-time = "2026-02-19T14:14:22.68Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ef/9ff941e2357d8ecdc577c999995055e42e9c382a13d72eff594a21d2c285/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b123fd3ef7d4b228381678fc3292b377e2a1f729a52beb31358313b49e2dcdb", size = 6641962, upload-time = "2026-02-19T14:14:43.196Z" }, - { url = "https://files.pythonhosted.org/packages/2e/67/a63b73e5e0d5434e388b5c74eb7f11191afe44ccc8a4cbd6055edc708aa9/pydantic_monty-0.0.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c61bc830acc85a4ca875903885eaa17322e8289d53e4ca6abe6a15a4a7998bd5", size = 6690453, upload-time = "2026-02-19T14:13:55.307Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f6/f16238540591b3bb829925f2911bd4a257da2ac5c39acaeef5f70cac8ee6/pydantic_monty-0.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a6b71a66837ca73e107e8bf31e1a715a7fe84b2d3d3b5e760bfeb206f00e68f6", size = 6236989, upload-time = "2026-02-19T14:14:17.242Z" }, - { url = "https://files.pythonhosted.org/packages/9a/21/0ff2e16249157a64b60e359e4d35c82559829e281bbd1ba42693af143136/pydantic_monty-0.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b8f9221339ff3ba95ded5f69760f974fdffdb41f19b5234b5ccf19b183667849", size = 6671096, upload-time = "2026-02-19T14:13:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/f4/53/8545e1c059225304938cf07d29aead0c7617ae05c6b2040957cd8f039a84/pydantic_monty-0.0.7-cp310-cp310-win32.whl", hash = "sha256:c6f2b930cdaac5dafca862813c3cf192071d93d3e1f15f952902dccec106b1d1", size = 6134622, upload-time = "2026-02-19T14:12:53.517Z" }, - { url = "https://files.pythonhosted.org/packages/0b/de/e7247a58787f0001f5119cc385f8f3868e7850b5342974534778dbaf900d/pydantic_monty-0.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:8e0fefba589a443538d4df90b2bfb4a23722bd838146f3419276f22f665aa2e0", size = 6694493, upload-time = "2026-02-19T14:14:30.997Z" }, - { url = "https://files.pythonhosted.org/packages/83/30/7f9432e9923b9b60c732364d75bce7ea4b22a6a18c6d7345078dac27d6e2/pydantic_monty-0.0.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:1ff6197ef4fd71e5f03c4b8d4228e387705fcad3798b1ea19c23594f7ea26660", size = 6265108, upload-time = "2026-02-19T14:13:31.46Z" }, - { url = "https://files.pythonhosted.org/packages/01/3f/ab7d83cf4dc0f9ac075d3edcee897f71a1efdd7659570a74abe1780cb769/pydantic_monty-0.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:649209a0a401969e86ac46014e8967636a7f5c4dbccd3bd22134eba8c3a7be6e", size = 6156865, upload-time = "2026-02-19T14:13:57.05Z" }, - { url = "https://files.pythonhosted.org/packages/98/f9/9471a56881ba8b2b87dc6bc274194fb3e0d58ad61d1ee17a6427690c951e/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:300dc5dcaae167f61540037cc5cb66ee63edb39298e04807472afd6f14c7d4cf", size = 6059238, upload-time = "2026-02-19T14:14:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/10/6a/99c6d9eadf38e64d7b198becf481642f1352882b79d12342d29f4984098e/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6392ab97cdd1290e6dfb7bdb13c328df75b08b3a25aa2a6f84b117c8f3688428", size = 6310805, upload-time = "2026-02-19T14:14:40.343Z" }, - { url = "https://files.pythonhosted.org/packages/11/9d/327dc638a17f1aefee843743382f0aa32efce297d6a43011025a1fc9fa7d/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac3a30019322d328ac6217c6bdbbe7c17b24fd2dd1015b4c334094213445cd30", size = 6859696, upload-time = "2026-02-19T14:14:29.198Z" }, - { url = "https://files.pythonhosted.org/packages/3f/38/ae57ee792a30c1421cdb84f738573c883989215658a590010eb8012139e6/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242d757c42ad53bab2be676588dd5b301e2e77420e79cc6f009256bf3b76472e", size = 6867309, upload-time = "2026-02-19T14:12:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/19/a2/b6bac68b3b100089dda7d45456260d73563ca76775bb83298120f2666853/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c9edcec7e6543bc1188a299debb706a47261b28cfb6157ec8181f70a463f735", size = 6640874, upload-time = "2026-02-19T14:12:49.002Z" }, - { url = "https://files.pythonhosted.org/packages/3d/04/cf4847759d9f5069daad414266502c42e59b2c93b496e76478afce861121/pydantic_monty-0.0.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e1b2f45bbeb09f68f99fd301fcd370f7b03152f2935a5c65533c86c11b9377eb", size = 6690344, upload-time = "2026-02-19T14:13:18.926Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e9/b3bcd311599e01c66d7821798ad926e51e02569ddeb57506c5db2df85cb0/pydantic_monty-0.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8275e24a44c43c0125c5d558ead78f750d891d6a11995bf750eabb0781086a40", size = 6235416, upload-time = "2026-02-19T14:13:16.188Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d8/b545fd3f3d473e47a081815b3521bab36f68788b3d921b4b03de648d4986/pydantic_monty-0.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1910fb89e52a2b3b8413cc4c30d3bc353f1a8a622e67e6d0c1030c5dc86931c", size = 6670652, upload-time = "2026-02-19T14:14:03.149Z" }, - { url = "https://files.pythonhosted.org/packages/4f/fe/e209b45ece1cdce88dad03033497e0f49525d348f5c5a2e60f7d6d78da1b/pydantic_monty-0.0.7-cp311-cp311-win32.whl", hash = "sha256:899f68f6b3a4808a6d73292f819cad85f50a4301b018322d2ce3473488c56959", size = 6133507, upload-time = "2026-02-19T14:12:55.433Z" }, - { url = "https://files.pythonhosted.org/packages/1d/70/6b8f6b0427b7c1bf5c1d6bffcf68c5f2902c8f31304eaf0ebed5b2d4d475/pydantic_monty-0.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:12456fbf22df015a78abcdb9f8bd1f8227673a93e781263ae9b9c6e2524b5cd6", size = 6693590, upload-time = "2026-02-19T14:14:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ce/7e/ca0884108c3237bb15bb2a1b3f24ddd957b9c750f1ed3211801497941999/pydantic_monty-0.0.7-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f35e18f284524d26d5f27084e2b93eb40139055bbf0cab6221a043eb5e9ce2dc", size = 6264252, upload-time = "2026-02-19T14:13:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5b/31f70c7792a857bacbdce90b8aae4629c31a9fec35f0116d91a2fb53241b/pydantic_monty-0.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e5d8924c65bb1ced60785a156e28c73f7f79f164b4f090dc26312c3917ffff7", size = 6133285, upload-time = "2026-02-19T14:14:46.577Z" }, - { url = "https://files.pythonhosted.org/packages/42/56/c92216c0427e8a10a01fa98f29252f6fabd8ca80ca193e0fd30fe28e65c1/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6279a468469d5a3b80d94dd0ab6110cd291a1dfbb057fa7d6dbad1f499be855d", size = 6059856, upload-time = "2026-02-19T14:13:02.594Z" }, - { url = "https://files.pythonhosted.org/packages/b5/a7/bc3e67b12d8a9da65f2677d9a48bc1e055a1d853d48572ba4845a64075cf/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb5feb69a5902d059db5dab269f90423b85a22163668422be47ccac8d7f7c44a", size = 6313780, upload-time = "2026-02-19T14:13:46.45Z" }, - { url = "https://files.pythonhosted.org/packages/c8/97/a9b856b17ee1e54892dafbb7ea29305520cae2dcd8aafe82a26a0edbc33c/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f1913e9729aa6711092ecbfce764df199a4787fe3e23a7ed74c78bf846579e", size = 6856827, upload-time = "2026-02-19T14:13:40.836Z" }, - { url = "https://files.pythonhosted.org/packages/98/57/2d8184b9f5a0b2b3bb47fdad7061c6a182699824efe6de9d8dd19ee68c0a/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec528f9f4194e6298757ad99e25da47f06f49ae2bc176ee26f49da5eb1dd7849", size = 6870737, upload-time = "2026-02-19T14:13:53.602Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b3/fe3d3eff82b41e517739841a492d7a48ea2daf8e7b822299b848b5d4c0aa/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75405b9186a9acfa49cd66aa339b5a2450d733a2d59fae20cc8be45e45204d5f", size = 6611843, upload-time = "2026-02-19T14:13:49.957Z" }, - { url = "https://files.pythonhosted.org/packages/ff/d2/fdd8fe135ea14e30b40adadc896dda6c805596688998c9bcdfd8d85a16cf/pydantic_monty-0.0.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1917e42fce4733f92f5f6ad64ae4d0e87abbf9fb284ef52589ac3e292e928bfe", size = 6692856, upload-time = "2026-02-19T14:13:00.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/a6/fdde6f8d76aa0cff4b53060b63d7f09dbb19da61a967cb4b3dfd972acf1c/pydantic_monty-0.0.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d72a5d4f3ee7f9d2630b0379e4cfb397e181eb0b16e8c49a03f80ba6471edb89", size = 6236587, upload-time = "2026-02-19T14:14:32.602Z" }, - { url = "https://files.pythonhosted.org/packages/1b/7e/0580bbc001a39252b2f7da4b7504ac10572e4ca0ec967aebc5a9d752b6f7/pydantic_monty-0.0.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20028220981516912f130986354ef6c926b98778146ca349560cb852e44d9ca6", size = 6672260, upload-time = "2026-02-19T14:13:26.527Z" }, - { url = "https://files.pythonhosted.org/packages/d8/53/578a7b781a5714db5c4b1989c6e876d30caa0adf8a5a4caad89abc306667/pydantic_monty-0.0.7-cp312-cp312-win32.whl", hash = "sha256:e28b1c3ed52892f8ac12ee0f2b535402dfe1cb1e5c18128f1cb69eb8b66c285a", size = 6131085, upload-time = "2026-02-19T14:14:13.296Z" }, - { url = "https://files.pythonhosted.org/packages/56/98/20bd45fcd472937b1b3438b7587e209e3cfd447c30d02f654b86b44adaad/pydantic_monty-0.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:031dfab63ff9d7acdc641852e0d822603038cef1c27c5060900b9fd51cc853d0", size = 6664431, upload-time = "2026-02-19T14:13:29.968Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fe/d8cb6c30d9d7bcc7d3c8d2c349a227e2a83cd1fbe7182f4941896eb35443/pydantic_monty-0.0.7-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:55d36818f8e35872ed35e395b41df8acc460bcdbbfd471fe0c39e293a1d50db5", size = 6262596, upload-time = "2026-02-19T14:14:15.2Z" }, - { url = "https://files.pythonhosted.org/packages/81/7a/f6b4881ca9779bd87eb8d8c0823133b56c232cf09d765c07a7f91d641490/pydantic_monty-0.0.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:acb437458d93d54a9658656545fb6c9b396dbe66f68633b3c57bfd2f4aa1d400", size = 6133793, upload-time = "2026-02-19T14:14:06.498Z" }, - { url = "https://files.pythonhosted.org/packages/39/b9/dfcffd95ff233b8c98db9254242d9c10190989762016d18509aa04d43b1b/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b196345ffa1997041cb870ea693148feafe270575e2c2963532eedde0e84dedf", size = 6059400, upload-time = "2026-02-19T14:13:38.953Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2e/d6ecef842024267ddf4128613342b8985a7444e74f3a4a312713c913a91a/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d471e3cfe77d62edaf43b7f0962b95270ee4243abea12cb8e8cf1ad972dc3612", size = 6312625, upload-time = "2026-02-19T14:13:36.901Z" }, - { url = "https://files.pythonhosted.org/packages/75/2e/e4a2a9fbc3640bcee15b80c2f8ba0f97bf989c58c01d6da187524f71d12b/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a67771afd385579bf3f894ce933fb9e467fba9a632ccf27246e271d448f6f5f", size = 6859902, upload-time = "2026-02-19T14:14:26.67Z" }, - { url = "https://files.pythonhosted.org/packages/d2/4a/7aaf5c793f52e3403892a2de1f5dd18ae38234d82111cc9b7d92443e5b0d/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf662e1bbee4ddd318d5b8bfa9233173045029be0f67f15f816955b246bb7ec0", size = 6870524, upload-time = "2026-02-19T14:13:28.208Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a9/c16f078864a273460923f1371b769c2719e1ce1ad86bc9031e3ed7fb3eae/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0151ff59a8a0d9e29ddb448affa33943108121e6e324795646a2f5facaf1a5d8", size = 6611960, upload-time = "2026-02-19T14:14:38.812Z" }, - { url = "https://files.pythonhosted.org/packages/20/d3/b3ef3432558a8cc9551d8b80a028a0f51cd2a518275932e03359eac3dc39/pydantic_monty-0.0.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:45f17a65134d3a0031e1f54d143770699f6a0ce92a1e74f0ae4914e52370f058", size = 6691834, upload-time = "2026-02-19T14:13:34.661Z" }, - { url = "https://files.pythonhosted.org/packages/ef/56/1ab5d1cbc0edfb522f0c28c9f5a7fc74eea6355234f73833087524d034bf/pydantic_monty-0.0.7-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ede6e68cb8a1f7216e26b0b2fb6cd0eae7a92104be8a49d1042e9e428de9262b", size = 6235704, upload-time = "2026-02-19T14:13:48.276Z" }, - { url = "https://files.pythonhosted.org/packages/c4/d1/cdebae67b0543f696ed7daff8587dc8a458e6552b52d5877cb8e55be74b4/pydantic_monty-0.0.7-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:4b2fd51eea05a0cc37bb91f326efdb1acbcd4b8262dac1c55aeb208e51254978", size = 6671530, upload-time = "2026-02-19T14:12:56.96Z" }, - { url = "https://files.pythonhosted.org/packages/9f/53/c0dacaec260b71050fd6b31d09570f9d74bbb2a2e9586032694e92b9fa59/pydantic_monty-0.0.7-cp313-cp313-win32.whl", hash = "sha256:40f2092970c5899ac2a2784d712a4c7e194b33cd0133315254e4baf141cd6c93", size = 6130341, upload-time = "2026-02-19T14:14:09.701Z" }, - { url = "https://files.pythonhosted.org/packages/3e/05/31490a7a899d8bbb2e513630ea6f591ceb8a111c91fe7573a96c9f6b6327/pydantic_monty-0.0.7-cp313-cp313-win_amd64.whl", hash = "sha256:42cee2646415bb9bd7da428d169783203618a418f960c6f75c7a74d6946d6b31", size = 6664341, upload-time = "2026-02-19T14:14:19.008Z" }, - { url = "https://files.pythonhosted.org/packages/99/15/64aff358df0b822dd22f212fec501e3944edffe978a4ab05530ea641dc68/pydantic_monty-0.0.7-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c97b2e1dcd0126417892595c1da724a8c4348f7dcec26ba774117bd51bde46f8", size = 6266090, upload-time = "2026-02-19T14:13:12.364Z" }, - { url = "https://files.pythonhosted.org/packages/64/90/7b5a4292eb9993eb8be9d958b5a57764818eeda471e3e79be7da4e9b49ba/pydantic_monty-0.0.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6f40e6e133b309ba733874f5980ab6cf867ec8cea2a6a389a641819cd8dcb7cd", size = 6152219, upload-time = "2026-02-19T14:13:20.734Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/2740af0157eb3c6f10c16b0d8376b8c9cf0b910720fe90229885bccb4a91/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57f2a327b6aa7402a2b2c3ddb3964bd45f12597f8f950dd4c5905b843b353b73", size = 6060942, upload-time = "2026-02-19T14:12:45.601Z" }, - { url = "https://files.pythonhosted.org/packages/f6/26/1cf235c2cc8e219a94ed8b11151280ba89e8020a475b6280c89e62f7275f/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:771f1f158af0de2480ea2a8862e4c0c7f79e9a13cc3e17529002e1abfc077f95", size = 6315477, upload-time = "2026-02-19T14:14:11.314Z" }, - { url = "https://files.pythonhosted.org/packages/b8/41/0faca7b9d8868822b7177ae941f193f397479bb114d3a6396466167a3198/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a4182312ee8c26d8834e76375b2b5c766cb5d86d1dcf1515aa95e02704fcad83", size = 6862130, upload-time = "2026-02-19T14:13:22.375Z" }, - { url = "https://files.pythonhosted.org/packages/34/7b/0f2bd4105a285f50f17af721e83a76ebc1186a9f07a2a29d6d576490a232/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8751696fe66fb1bdd429d98fde3a7f4b7dce9cb45f22095e28b96b690a422a2c", size = 6872292, upload-time = "2026-02-19T14:14:08.078Z" }, - { url = "https://files.pythonhosted.org/packages/c9/18/4380820d62d348afb1355814ea674788e28db7a73108a486e0b8898987de/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b73cd1137bb4fd9bf95ed5f87e48d960f7d556c30eeafde6996f908abbf183", size = 6636567, upload-time = "2026-02-19T14:14:01.464Z" }, - { url = "https://files.pythonhosted.org/packages/3e/57/29e5f89a558a6409d514bb2790c72442ec65804cbf1df6a870bbc0038673/pydantic_monty-0.0.7-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1fa45a66757de5ea45c0809e15643bc2521959dbe2bc231694b22fa189decc9", size = 6693896, upload-time = "2026-02-19T14:14:34.128Z" }, - { url = "https://files.pythonhosted.org/packages/f6/26/5886d0f57ddb5ddf766ee2d0a4b3032267be9efd49a7c95c4d87a0b4b6a9/pydantic_monty-0.0.7-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ffe8122db9f0f64619a66f4cee2f577245ba59158b7d651a2eae08df691d34f9", size = 6236867, upload-time = "2026-02-19T14:14:20.704Z" }, - { url = "https://files.pythonhosted.org/packages/b5/72/1bb8741baf84f217d92291b862f8a8cb64d735fe4be20be2827fdf787593/pydantic_monty-0.0.7-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2122a5b6df53843329af01f6671300747449608d9cdf88ae5f9cf9977794e7a2", size = 6673504, upload-time = "2026-02-19T14:14:24.517Z" }, - { url = "https://files.pythonhosted.org/packages/d1/14/a4ff2bfe46350ffde4b5edc1f293b252cff90063f1f4cece49affe5a6462/pydantic_monty-0.0.7-cp314-cp314-win32.whl", hash = "sha256:bfbea2eddb9eef186326a6dfb27d79f8de434d7a3979f36f03b04216234a0275", size = 6131872, upload-time = "2026-02-19T14:13:14.437Z" }, - { url = "https://files.pythonhosted.org/packages/60/1f/d873f280aae5cbd27021189843fbd5f77be4262a7654ef30445d984518ab/pydantic_monty-0.0.7-cp314-cp314-win_amd64.whl", hash = "sha256:1b750afceef78f5c5d3e3e3c32a8060b3a7e1b97e3a00ac2bede5bc5e87cde8f", size = 6687125, upload-time = "2026-02-19T14:13:04.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/84/fcf79d08ff6934a61eb5fd6cb7323c16439dc5018c1bf403fa75141ce126/pydantic_monty-0.0.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:8e35f58be11b0065bad18df2e55f43ad64b246844d0a329edba4a2cc8c6127aa", size = 6921535, upload-time = "2026-03-28T13:18:53.131Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c6/fe686c35ca5abcd6382c681c8156fa3404dec493a4ce85cdcf2f310edd19/pydantic_monty-0.0.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cde0592a38b61981432c39e36616628a85d776e436ef8973e8aa2346644a3c34", size = 6975485, upload-time = "2026-03-28T13:18:10.52Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/269cf31b268b90b0d9008fd3de12823bfaf294eb702309d6aead7345f805/pydantic_monty-0.0.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4e44da4b814a8c9a5976cd4898c71f9b233ba58905c8653798f3d2ecd65b4d3", size = 6724193, upload-time = "2026-03-28T13:17:51.604Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5f/fb8f6b16ee1a9b23fa976f9438ca2b6d1936f5e9fd39b582f67bc2560e1e/pydantic_monty-0.0.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:844c70ce47ea23caa5eae26e44d0a02d0f5436982c816526fa5b32e06348f438", size = 7009728, upload-time = "2026-03-28T13:19:00.752Z" }, + { url = "https://files.pythonhosted.org/packages/af/4d/af238d00e7e49142e924ebe425dc77c62064bf894dff77c8d06eab2b21e2/pydantic_monty-0.0.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2670b493cce54aca3f6b3de3427eeca1579f763e2e1ed565fec26102cbdf8af", size = 7544859, upload-time = "2026-03-28T13:18:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1c/3e68b87d2f0cabd7dc6460b9107d7ab164430e50c8ee6d06b877d587c33b/pydantic_monty-0.0.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3cf93c0503ee3606d0412e396f9790d828fb38bc7ef2d6c7ab3afbced277ab19", size = 7749043, upload-time = "2026-03-28T13:17:26.306Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/f9c8a2726950b3779d9e459049fc233cdd30c9804d41713283d030812809/pydantic_monty-0.0.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4018b276648da472438a480c3786c31b1af7d0533271a547670dfe9f45b9cc4d", size = 7511768, upload-time = "2026-03-28T13:19:26.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/d8e09f87594a31d7d3c6fa43dc88f476a867a364f9c88587ed8068546982/pydantic_monty-0.0.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f0535a1cb1f67b195617bafc9f464afddff0a1a09662abcbf5112835bf2da91", size = 7450754, upload-time = "2026-03-28T13:18:04.505Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/8732c3fa3dcce727b5249f794ca6b55c584e1efaf8880e2f2d0263607bc9/pydantic_monty-0.0.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e4a4a844e81d395c6d6118834c2bc4781d9d46dc900116addc68ba989cac604e", size = 6902296, upload-time = "2026-03-28T13:18:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/d7a3592f1825cb8fefd3a01a92f4fc863aa534d6e71be4bab3f76f7d206a/pydantic_monty-0.0.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:90f34a6cefab429baffb2b678e57ba3b51ac8126651b84db46385e8637b7fca5", size = 7343642, upload-time = "2026-03-28T13:17:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cd/503a62e1667d28a5cfbaa08615fb439c21383e01fdf72065382d7e6df06e/pydantic_monty-0.0.9-cp310-cp310-win32.whl", hash = "sha256:d7998336640ab9011d51d4efaf9592087123298fc2b2007770594e68822594c5", size = 6818778, upload-time = "2026-03-28T13:19:24.4Z" }, + { url = "https://files.pythonhosted.org/packages/e2/48/caf0056e2962e573c518a9613805ce8eb80f0b4d60ed8be375b38d79cc33/pydantic_monty-0.0.9-cp310-cp310-win_amd64.whl", hash = "sha256:a2d7a1c5b5452c24a296e4f56f55e3a9adc89e41b8f428a49339cf3329c74a7c", size = 7600674, upload-time = "2026-03-28T13:18:19.718Z" }, + { url = "https://files.pythonhosted.org/packages/fd/60/450ee2502be76871a152b98afeb65842bb1312a9681a9a66624fa6ae5d28/pydantic_monty-0.0.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e431e20391c93ffb1ebc2afc2eb674a43fd048db17aadc65e45ae0b0df8b896d", size = 6921183, upload-time = "2026-03-28T13:18:06.729Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/e94f560264690096f7ec93cf1b80e9b466146da3b0b2b197d5d9d006ebc3/pydantic_monty-0.0.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e551a93851b0befd86fb403e61c74baf3a12427ed40512286538d1d3b76c4f0", size = 6974070, upload-time = "2026-03-28T13:17:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a9/637d5a8e75bd992f56cbfe06815785c3e805f43306a70add896a363df019/pydantic_monty-0.0.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df46f7624fc792e52e2dfea9f4bc1169ed4f127b1bb93e328c19c3f8a8230efa", size = 6722547, upload-time = "2026-03-28T13:18:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/f9/db/e2e6a73965782d5be5af417a5dd4ac5804fe15ca3a6f07f8d46b26b6db76/pydantic_monty-0.0.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:befde2fdc58502d45a1b2d8c9d55c7fa668fd96283020815cc40494fa6c06260", size = 7009055, upload-time = "2026-03-28T13:17:34.973Z" }, + { url = "https://files.pythonhosted.org/packages/51/e1/c1c741df43e85ebd13ecf9f4d00160a325b15ef23122583a2dc681d37d37/pydantic_monty-0.0.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e51cf69ce5e4b9a0bddc944faf72f04dd2fa00e33af53f5d4c78a3b70de6b957", size = 7544714, upload-time = "2026-03-28T13:17:58.257Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/a28b67eb19cf3a55fafb2ffa27e39ade45f6f960f774e92efa06471a9b5a/pydantic_monty-0.0.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdc285726097c53d0e3de4a7da0749a3ea5f32e15bbbdabfc73699a503f9e1b", size = 7748528, upload-time = "2026-03-28T13:18:00.541Z" }, + { url = "https://files.pythonhosted.org/packages/a9/75/0116c72c722a590a59e3b34af4698b6b4c0ca08c1375b02a036280eba7e8/pydantic_monty-0.0.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acf584587e86813a807631c84e4f9a3291ae8372a2fe6b11f04a265fea7b6aaf", size = 7510877, upload-time = "2026-03-28T13:17:55.683Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d5/718f84c00874ac7cd05b2ac180a3163be06bbed3865812b5b1b2e18113e8/pydantic_monty-0.0.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d1b3717be663652c68e509442d803c1d8d0788a29907aa5ebc5afcb09eba9d69", size = 7450465, upload-time = "2026-03-28T13:19:16.133Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/e115c6f7717fed85ff282363b526f1be59f87f08bf885ef2f4a6d1821d2c/pydantic_monty-0.0.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:eda40941506e9bab6e3eafd43e90e62e870374e4800c7164add883ca58eb9497", size = 6900797, upload-time = "2026-03-28T13:18:35.182Z" }, + { url = "https://files.pythonhosted.org/packages/15/76/cca9e4bacd19ba26acf513d7a7ffdb5e67f5b6caf4da4a601a8d277895de/pydantic_monty-0.0.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:46ab71bdc3673dd9661d414a00f0cb13f3813e20578e9f6683b9de0e675adc23", size = 7343094, upload-time = "2026-03-28T13:19:12.335Z" }, + { url = "https://files.pythonhosted.org/packages/40/fc/70546f162a57ae8666accda37640f3bc4f0ae265d02d7bf58969fddde79f/pydantic_monty-0.0.9-cp311-cp311-win32.whl", hash = "sha256:75d5cae40363ef685e2328ff1df14ba4e4181e99294fc03586d5c5222b7174de", size = 6818506, upload-time = "2026-03-28T13:19:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/c3/30/f745397ad13c28ac295ffe93ac97eb341d6f00b5b7e4cb902b6da52feaae/pydantic_monty-0.0.9-cp311-cp311-win_amd64.whl", hash = "sha256:e684dd57adba9a88dab7de60c1ae89f613d14a5a8074c7efea58a6a0fa8ff941", size = 7600048, upload-time = "2026-03-28T13:18:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/4aaf85c79df3b47767d4c065a08b60c59f76b7611cbe29c7024e641a6dc1/pydantic_monty-0.0.9-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2c7b92ce83ba9f7d433f445da525243ce267b362a9fc8ecc15b322da91a595e2", size = 6918436, upload-time = "2026-03-28T13:18:58.99Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/a0a947eebb2e2a0db1775e374bc11dab549f46d6505d7d691368be984dcf/pydantic_monty-0.0.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b3d8e128f1822af6572bf6626ba9c2a2be53a8b291216809fb89e719272ea66", size = 6951931, upload-time = "2026-03-28T13:19:08.429Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e9/cf75febeafde9f6a3acddc6423bef3aed55525105bb1e2f32ee63e5c92a5/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a066a552ec3b358af5da11bf02ed8f3b2c0ae7b566a9bd38545176a6578d0bae", size = 6722630, upload-time = "2026-03-28T13:18:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/47b15bca41854ae41f0a83ea57a1aa0fad0805add7ada8b03f7f3c361e0f/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b51a3f809ae84dbd85bfcb307e98e759707a6c8097178806613c565fa6155ba4", size = 7010504, upload-time = "2026-03-28T13:18:17.094Z" }, + { url = "https://files.pythonhosted.org/packages/a1/eb/969453fe63080c9cb1461cf4e13821058bbffb7da83bda88bc02cd9948ee/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:83b01931866e73caaaec308aadfbe2655006fc456e28c36845306d699359e11b", size = 7545150, upload-time = "2026-03-28T13:18:41.873Z" }, + { url = "https://files.pythonhosted.org/packages/45/86/802d7a6a47c97ea5bae93cff711a45a7be1223da279f21a79d3cba66b85b/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fa858e49bde281fa48e27961813740f0cc47e6ccf4f3a438e5e513c9e328978", size = 7750797, upload-time = "2026-03-28T13:18:51.355Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1e/e13202123771aaeb4ec0b86b85236923d57ea4fb661a6c8c01ebb84f7ce6/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8525c9f7baff77097786da6e9b51ad21f09242b5ff0d590f007c0d7f350fb3af", size = 7481146, upload-time = "2026-03-28T13:17:45.45Z" }, + { url = "https://files.pythonhosted.org/packages/3d/af/c205a267c799862faf5a186e7612a744af1e1deeaa9c6f40888d6b08e800/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59fcfd27dc7e65a9ac909f964c9ab5aec083cd6d3781ac0ff5afb06f481adf34", size = 7454819, upload-time = "2026-03-28T13:17:47.425Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a6/47066efd5c0e25cd6c4ea15ee91fd1291be2bd07744edaeab66c2d0e659f/pydantic_monty-0.0.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c30748cd5c0d9d2028d372372cd0bd9476b8508632372ec285d97f2ee2e9b40e", size = 6901304, upload-time = "2026-03-28T13:19:14.133Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/ee0edcd279d5ff6c74ec31db27777809d03d9960f32ba50e9b43765e173c/pydantic_monty-0.0.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:987e5b07ed56431bc94a586a7aff777c9928675bb938e8c15906dc8da49ac012", size = 7342554, upload-time = "2026-03-28T13:18:02.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/8f/a59673fc3d543916a7866e028d29ee2ee2c1493c4d804454be57ad2e081a/pydantic_monty-0.0.9-cp312-cp312-win32.whl", hash = "sha256:02d9d08eb0affc79491eebae0caed5559d5370cf6b806a9e1d67056bf3700c6d", size = 6816176, upload-time = "2026-03-28T13:17:41.153Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1c/ff4618ec34f67eade81ca2405d7da529ff3cb94a7c23034aa86a31bf3998/pydantic_monty-0.0.9-cp312-cp312-win_amd64.whl", hash = "sha256:7b705f76bb0b5c8307da564db06d550c24cd42fb6ece205e89ff8f2c24b9b8cf", size = 7571602, upload-time = "2026-03-28T13:17:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/89/f7/8b84b390527dad31c3a011a2242452a21e8199823d3bfc9190f4830c7837/pydantic_monty-0.0.9-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c3211eff69511cbf3a3b4422c59851bfb7121cd8757da74d2e10244795d2d178", size = 6917197, upload-time = "2026-03-28T13:19:10.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/60/815b882e31683fd8e0b8fc19d7aa772df92e5fff68d02b39b4b5b7bd83e7/pydantic_monty-0.0.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:586d68268ba74b76be7e88c5e7bfec6710466144b53a336bcc840752ed3efabc", size = 6951689, upload-time = "2026-03-28T13:17:30.929Z" }, + { url = "https://files.pythonhosted.org/packages/51/4b/2c7e4549251e300633da6afc8a1f40fc6e196d220e3b5773b9b13e5086f7/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9ecb857f814075f40cce780cdff4e661cd8f9e4139f5423d2e7db87be2f12ab", size = 6722305, upload-time = "2026-03-28T13:18:12.397Z" }, + { url = "https://files.pythonhosted.org/packages/27/c4/c3eb404eaf8f4f3cdb870795649c156ff4de8ed18c8a6ee28b4a9de26392/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:36c70b783b54891b33652852a8b97196550c1a2208a20ed527a11322c026c0b6", size = 7010210, upload-time = "2026-03-28T13:18:47.805Z" }, + { url = "https://files.pythonhosted.org/packages/a4/02/b3cb448bec463f5ade5415bd8360e9e1abe9ae5922fa7279f81fb71fede3/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:83ecdae13224b54e0fe1081249c7ba903603fc1f91e9104463365221dfedb6a7", size = 7543621, upload-time = "2026-03-28T13:18:21.69Z" }, + { url = "https://files.pythonhosted.org/packages/a8/22/d5bad5a2c26b3a9f3e410b19c580b558a29c86cc4432acb4699357e6a1f2/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d758730fc2c52916a787a3b98187166df57ae5410df83a1232b03f28a1648c68", size = 7750752, upload-time = "2026-03-28T13:18:37.48Z" }, + { url = "https://files.pythonhosted.org/packages/09/4e/b5fdb4d407351405172c7ec1ae3cca391178edbfc963ad7c2f72c60f6219/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30167ac5da02f8c787abf8fd5b9078d278f1e0d7d34228ab533f6895a6cfaeae", size = 7480766, upload-time = "2026-03-28T13:18:31.434Z" }, + { url = "https://files.pythonhosted.org/packages/df/50/ee98ba9a03024a69a4a959e0876e9ac41d87128e2ce38035eb071bd599d7/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a3f9be27c3d919273785128975f3ef77ff9f4acd0241f087670385ca1035661", size = 7453527, upload-time = "2026-03-28T13:18:24.052Z" }, + { url = "https://files.pythonhosted.org/packages/06/8f/1f724eb0e6bd617c4dc406d9602658546bab311d9cf8e6b1b3a18ab99cfb/pydantic_monty-0.0.9-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6c95adfa982a1f5e54661af16d85cb9a3eb063d1b5ad573d4725f1f7f3e58f99", size = 6900638, upload-time = "2026-03-28T13:18:54.918Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9e/ded78efb6646a772710413ff8531c57a2c53a5080c25e0d05cd7741157ed/pydantic_monty-0.0.9-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d682cce29303913991464f5c874e28ff3f9a10b1d6feb0bd0a8aa1d0344ee8d3", size = 7342105, upload-time = "2026-03-28T13:17:32.895Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d5/06070569726af1ff0f6ae4a4d48eadbcd3df2be3d52430f813176f56e249/pydantic_monty-0.0.9-cp313-cp313-win32.whl", hash = "sha256:cf409b859bb23fbe95051dbf5ffd093a3324cd610547783f6a47062ab2db11dc", size = 6815315, upload-time = "2026-03-28T13:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/e8/34/bfe47278242f84be0cd3687cb8a6165df22335ba3b3940596bf4681bfeba/pydantic_monty-0.0.9-cp313-cp313-win_amd64.whl", hash = "sha256:565ef2628b5fd840c178b6c6c223d74e9fd3c28dd83a405ae0b87f864fa37c94", size = 7571256, upload-time = "2026-03-28T13:17:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/32/fe/1ab7a8fd72456f5cad945260961aeeb217d910f0a44edc0f8fa79af26857/pydantic_monty-0.0.9-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:eef159f7a1496806d787e38abe271b8e39ea777e60f7bab06eaa60ee7224f619", size = 6919711, upload-time = "2026-03-28T13:19:21.869Z" }, + { url = "https://files.pythonhosted.org/packages/93/55/e9d476b643107a07a9bb540cc9313d0db614479b38817a0ba2df5d6c03ba/pydantic_monty-0.0.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1e91005ea5c532c03a35a4fc589c2a73f647edf38f3a02d8438782f184726e2", size = 6970765, upload-time = "2026-03-28T13:19:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/d2/24/b177895c9799b2a790eb055149774120c11144551dd6d97232242b4cb1d3/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d547c5ffc96dcb0574de83551dd4ed184df47e66f39600059230a17ac100185", size = 6723034, upload-time = "2026-03-28T13:18:33.47Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9b/ff8c9e948d8a28e9f03feb93813844b3095f943b63455f4f52aedf8c2582/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5611e32c3b4ca080b14b61be351c76f81e9e3cbc6c622663ffde964c06ee87c2", size = 7011075, upload-time = "2026-03-28T13:19:02.952Z" }, + { url = "https://files.pythonhosted.org/packages/b5/52/165067cfb23ba7c31eb6ec3aa335900f58b9e4a7d831ef7bf6e07f50804e/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b25cf37c2e680b4ed1e290cbd17f607f621caad340ffc05733101c735d2a868", size = 7541685, upload-time = "2026-03-28T13:19:19.918Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/ee47760be7a78634699ba1170a468d03dbcb51eaaae732eb579cc94382e1/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccb49df19577e97c55dd9853ec1e57936c352ef9e34321c71fde61020a751525", size = 7751220, upload-time = "2026-03-28T13:17:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/93/9d/d5a6f0eba4cd3b626d2d71f93f1dbd4f7e7cf83e68dd88231ee92681d15b/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da129cfe11b084972b3d889e48db80084efd355a1f9ab0efc4fdcc0eda8d25ff", size = 7502146, upload-time = "2026-03-28T13:17:53.523Z" }, + { url = "https://files.pythonhosted.org/packages/16/79/7f257f08357bf6e4854f65900046a14c008eaabc31e604daa533d5efd7bf/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a6142fbe432e2c9b58383320f489fccca141d442f3746c93e2aca995a9302a6", size = 7455712, upload-time = "2026-03-28T13:18:43.891Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bf/09840855d51f9a22022932b0a2c82cab73810b07de44b122427e83edffe0/pydantic_monty-0.0.9-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:829cc76d7b77ee250b0a5d34ee29b18bf5374ad54102c4a229143bd054dd5c5c", size = 6902487, upload-time = "2026-03-28T13:18:49.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a4/c158c9040b7c19b0f1cab9c67260d9e1068bef5dc48e7f07f8335eb1e6ee/pydantic_monty-0.0.9-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:66f8cd4139a932cd5b4772927656dcfbf71b16633e08872030768970db12f3a2", size = 7343760, upload-time = "2026-03-28T13:18:25.865Z" }, + { url = "https://files.pythonhosted.org/packages/77/95/5f293008d3b52ed87e4614a215a71163f4a04aa942cd009906008ae397b9/pydantic_monty-0.0.9-cp314-cp314-win32.whl", hash = "sha256:c12223734c1a20ab0fdec1a5ac187ebe7000a7425addd0a0d348d765f03d3bed", size = 6816935, upload-time = "2026-03-28T13:17:24.154Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0f/11457bfa549750da0278f7c4f7dbc793bb6754aa3fab21a92fbfc3bdf8af/pydantic_monty-0.0.9-cp314-cp314-win_amd64.whl", hash = "sha256:a21c26fb13c776118000f3d6989dcc3b1d8171250ba79f2b5dc4e912b53be658", size = 7592268, upload-time = "2026-03-28T13:19:04.742Z" }, ] [[package]] name = "pydantic-settings" -version = "2.13.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/a1/ae859ffac5a3338a66b74c5e29e244fd3a3cc483c89feaf9f56c39898d75/pydantic_settings-2.13.0.tar.gz", hash = "sha256:95d875514610e8595672800a5c40b073e99e4aae467fa7c8f9c263061ea2e1fe", size = 222450, upload-time = "2026-02-15T12:11:23.476Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/1a/dd1b9d7e627486cf8e7523d09b70010e05a4bc41414f4ae6ce184cf0afb6/pydantic_settings-2.13.0-py3-none-any.whl", hash = "sha256:d67b576fff39cd086b595441bf9c75d4193ca9c0ed643b90360694d0f1240246", size = 58429, upload-time = "2026-02-15T12:11:22.133Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] name = "pydocket" -version = "0.18.0" +version = "0.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, - { name = "croniter" }, + { name = "cronsim" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "fakeredis", extra = ["lua"] }, { name = "opentelemetry-api" }, @@ -2179,18 +2228,18 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "uncalled-for" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d2/da/5f76e42214c76402e1a2b4b59610211635c1068cab85509c78f1ca49a385/pydocket-0.18.0.tar.gz", hash = "sha256:cd5b6e7386331ca05a0163401f392b08b07e61342b5333c3ece6a7ca5435f984", size = 354637, upload-time = "2026-03-02T16:22:17.356Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/5f/82dde9fb6099b960a4203596d3b755d1bd2c0d0210fea104d015d6515d7f/pydocket-0.18.2.tar.gz", hash = "sha256:cc2051d15557f83bb164a83b0743fa9c12c2bfe9a9145cff3a5922b4935ce4f5", size = 354762, upload-time = "2026-03-10T13:09:22.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/57/ac0d47cd3550d859138647c2c4fbd53a2db05db8729433eaa6128e9964ba/pydocket-0.18.0-py3-none-any.whl", hash = "sha256:d995d9a3c88af0402fda640c18e1b51561041b9e3af1a92dce2fdc6c8f6c7090", size = 98848, upload-time = "2026-03-02T16:22:15.792Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cf/8c1b6340baf81d7f6c97fe0181bda7cfd500d5e33bf469fbffbdae07b3c9/pydocket-0.18.2-py3-none-any.whl", hash = "sha256:19e48de15e83370f750e362610b777533ff9c0fa48bf36766ed581f91d266556", size = 99041, upload-time = "2026-03-10T13:09:20.598Z" }, ] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2251,11 +2300,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies] @@ -2324,30 +2376,30 @@ wheels = [ [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] name = "pytest-env" -version = "1.3.2" +version = "1.6.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/43/ad/dd32e4614fb68ad980c949fd4299f8c6a8d4874e24ec8d222c056efb4741/pytest_env-1.3.2.tar.gz", hash = "sha256:f091a2c6a8eb91befcae2b4c1bd2905a51f33bc1c6567707b7feed4e51b76b47", size = 12009, upload-time = "2026-02-11T22:09:49.168Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/ad/d793670b26f4fb82e974dbff20d05782ebb23490b08987976cdc62d854bb/pytest_env-1.3.2-py3-none-any.whl", hash = "sha256:e8626b776a035112a8ad58fcc9e04926868c58f15225de484de7c8af4b4b526c", size = 7864, upload-time = "2026-02-11T22:09:47.775Z" }, + { 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" }, ] [[package]] @@ -2421,34 +2473,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-json-logger" -version = "4.0.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, ] [[package]] @@ -2460,15 +2500,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, -] - [[package]] name = "pywin32" version = "311" @@ -2566,33 +2597,33 @@ wheels = [ [[package]] name = "redis" -version = "7.2.0" +version = "7.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" }, + { 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" }, ] [[package]] name = "referencing" -version = "0.36.2" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] name = "requests" -version = "2.32.5" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2600,22 +2631,22 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { 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" }, ] [[package]] name = "rich" -version = "14.3.2" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, + { 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" }, ] [[package]] @@ -2753,41 +2784,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - [[package]] name = "ruff" -version = "0.15.1" +version = "0.15.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, - { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, - { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, - { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, - { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, - { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, - { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, - { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, - { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, - { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, - { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, + { 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" }, ] [[package]] @@ -2812,15 +2831,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -2841,15 +2851,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.2.0" +version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" }, + { 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" }, ] [[package]] @@ -2868,15 +2878,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] @@ -2884,8 +2894,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [ @@ -2903,56 +2913,56 @@ wheels = [ [[package]] name = "tomli" -version = "2.4.0" +version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] @@ -2978,31 +2988,31 @@ wheels = [ [[package]] name = "ty" -version = "0.0.20" +version = "0.0.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/95/8de69bb98417227b01f1b1d743c819d6456c9fd140255b6124b05b17dfd6/ty-0.0.20.tar.gz", hash = "sha256:ebba6be7974c14efbb2a9adda6ac59848f880d7259f089dfa72a093039f1dcc6", size = 5262529, upload-time = "2026-03-02T15:51:36.587Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/94/4879b81f8681117ccaf31544579304f6dc2ddcc0c67f872afb35869643a2/ty-0.0.26.tar.gz", hash = "sha256:0496b62405d62de7b954d6d677dc1cc5d3046197215d7a0a7fef37745d7b6d29", size = 5393643, upload-time = "2026-03-26T16:27:11.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/2c/718abe48393e521bf852cd6b0f984766869b09c258d6e38a118768a91731/ty-0.0.20-py3-none-linux_armv6l.whl", hash = "sha256:7cc12769c169c9709a829c2248ee2826b7aae82e92caeac813d856f07c021eae", size = 10333656, upload-time = "2026-03-02T15:51:56.461Z" }, - { url = "https://files.pythonhosted.org/packages/41/0e/eb1c4cc4a12862e2327b72657bcebb10b7d9f17046f1bdcd6457a0211615/ty-0.0.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b777c1bf13bc0a95985ebb8a324b8668a4a9b2e514dde5ccf09e4d55d2ff232", size = 10168505, upload-time = "2026-03-02T15:51:51.895Z" }, - { url = "https://files.pythonhosted.org/packages/89/7f/10230798e673f0dd3094dfd16e43bfd90e9494e7af6e8e7db516fb431ddf/ty-0.0.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b2a4a7db48bf8cba30365001bc2cad7fd13c1a5aacdd704cc4b7925de8ca5eb3", size = 9678510, upload-time = "2026-03-02T15:51:48.451Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3d/59d9159577494edd1728f7db77b51bb07884bd21384f517963114e3ab5f6/ty-0.0.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6846427b8b353a43483e9c19936dc6a25612573b44c8f7d983dfa317e7f00d4c", size = 10162926, upload-time = "2026-03-02T15:51:40.558Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a8/b7273eec3e802f78eb913fbe0ce0c16ef263723173e06a5776a8359b2c66/ty-0.0.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245ceef5bd88df366869385cf96411cb14696334f8daa75597cf7e41c3012eb8", size = 10171702, upload-time = "2026-03-02T15:51:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/9f/32/5f1144f2f04a275109db06e3498450c4721554215b80ae73652ef412eeab/ty-0.0.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4d21d1cdf67a444d3c37583c17291ddba9382a9871021f3f5d5735e09e85efe", size = 10682552, upload-time = "2026-03-02T15:51:33.102Z" }, - { url = "https://files.pythonhosted.org/packages/6a/db/9f1f637310792f12bd6ed37d5fc8ab39ba1a9b0c6c55a33865e9f1cad840/ty-0.0.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd4ffd907d1bd70e46af9e9a2f88622f215e1bf44658ea43b32c2c0b357299e4", size = 11242605, upload-time = "2026-03-02T15:51:34.895Z" }, - { url = "https://files.pythonhosted.org/packages/1a/68/cc9cae2e732fcfd20ccdffc508407905a023fc8493b8771c392d915528dc/ty-0.0.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6594b58d8b0e9d16a22b3045fc1305db4b132c8d70c17784ab8c7a7cc986807", size = 10974655, upload-time = "2026-03-02T15:51:46.011Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c1/b9e3e3f28fe63486331e653f6aeb4184af8b1fe80542fcf74d2dda40a93d/ty-0.0.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3662f890518ce6cf4d7568f57d03906912d2afbf948a01089a28e325b1ef198c", size = 10761325, upload-time = "2026-03-02T15:51:26.818Z" }, - { url = "https://files.pythonhosted.org/packages/39/9e/67db935bdedf219a00fb69ec5437ba24dab66e0f2e706dd54a4eca234b84/ty-0.0.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e3ffbae58f9f0d17cdc4ac6d175ceae560b7ed7d54f9ddfb1c9f31054bcdc2c", size = 10145793, upload-time = "2026-03-02T15:51:38.562Z" }, - { url = "https://files.pythonhosted.org/packages/c7/de/b0eb815d4dc5a819c7e4faddc2a79058611169f7eef07ccc006531ce228c/ty-0.0.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:176e52bc8bb00b0e84efd34583962878a447a3a0e34ecc45fd7097a37554261b", size = 10189640, upload-time = "2026-03-02T15:51:50.202Z" }, - { url = "https://files.pythonhosted.org/packages/b8/71/63734923965cbb70df1da3e93e4b8875434e326b89e9f850611122f279bf/ty-0.0.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2bc73025418e976ca4143dde71fb9025a90754a08ac03e6aa9b80d4bed1294b", size = 10370568, upload-time = "2026-03-02T15:51:42.295Z" }, - { url = "https://files.pythonhosted.org/packages/32/a0/a532c2048533347dff48e9ca98bd86d2c224356e101688a8edaf8d6973fb/ty-0.0.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52f7c9ec6e363e094b3c389c344d5a140401f14a77f0625e3f28c21918552f5", size = 10853999, upload-time = "2026-03-02T15:51:58.963Z" }, - { url = "https://files.pythonhosted.org/packages/48/88/36c652c658fe96658043e4abc8ea97801de6fb6e63ab50aaa82807bff1d8/ty-0.0.20-py3-none-win32.whl", hash = "sha256:c7d32bfe93f8fcaa52b6eef3f1b930fd7da410c2c94e96f7412c30cfbabf1d17", size = 9744206, upload-time = "2026-03-02T15:51:54.183Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a7/a4a13bed1d7fd9d97aaa3c5bb5e6d3e9a689e6984806cbca2ab4c9233cac/ty-0.0.20-py3-none-win_amd64.whl", hash = "sha256:a5e10f40fc4a0a1cbcb740a4aad5c7ce35d79f030836ea3183b7a28f43170248", size = 10711999, upload-time = "2026-03-02T15:51:29.212Z" }, - { url = "https://files.pythonhosted.org/packages/8d/7e/6bfd748a9f4ff9267ed3329b86a0f02cdf6ab49f87bc36c8a164852f99fc/ty-0.0.20-py3-none-win_arm64.whl", hash = "sha256:53f7a5c12c960e71f160b734f328eff9a35d578af4b67a36b0bb5990ac5cdc27", size = 10150143, upload-time = "2026-03-02T15:51:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/83/24/99fe33ecd7e16d23c53b0d4244778c6d1b6eb1663b091236dcba22882d67/ty-0.0.26-py3-none-linux_armv6l.whl", hash = "sha256:35beaa56cf59725fd59ab35d8445bbd40b97fe76db39b052b1fcb31f9bf8adf7", size = 10521856, upload-time = "2026-03-26T16:27:06.335Z" }, + { url = "https://files.pythonhosted.org/packages/55/97/1b5e939e2ff69b9bb279ab680bfa8f677d886309a1ac8d9588fd6ce58146/ty-0.0.26-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:487a0be58ab0eb02e31ba71eb6953812a0f88e50633469b0c0ce3fb795fe0fa1", size = 10320958, upload-time = "2026-03-26T16:27:13.849Z" }, + { url = "https://files.pythonhosted.org/packages/71/25/37081461e13d38a190e5646948d7bc42084f7bd1c6b44f12550be3923e7e/ty-0.0.26-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a01b7de5693379646d423b68f119719a1338a20017ba48a93eefaff1ee56f97b", size = 9799905, upload-time = "2026-03-26T16:26:55.805Z" }, + { url = "https://files.pythonhosted.org/packages/a1/1c/295d8f55a7b0e037dfc3a5ec4bdda3ab3cbca6f492f725bf269f96a4d841/ty-0.0.26-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:628c3ee869d113dd2bd249925662fd39d9d0305a6cb38f640ddaa7436b74a1ef", size = 10317507, upload-time = "2026-03-26T16:27:31.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/62/48b3875c5d2f48fe017468d4bbdde1164c76a8184374f1d5e6162cf7d9b8/ty-0.0.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:63d04f35f5370cbc91c0b9675dc83e0c53678125a7b629c9c95769e86f123e65", size = 10319821, upload-time = "2026-03-26T16:27:29.647Z" }, + { url = "https://files.pythonhosted.org/packages/ff/28/cfb2d495046d5bf42d532325cea7412fa1189912d549dbfae417a24fd794/ty-0.0.26-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a53c4e6f6a91927f8b90e584a4b12bcde05b0c1870ddff8d17462168ad7947a", size = 10831757, upload-time = "2026-03-26T16:27:37.441Z" }, + { url = "https://files.pythonhosted.org/packages/26/bf/dbc3e42f448a2d862651de070b4108028c543ca18cab096b38d7de449915/ty-0.0.26-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:caf2ced0e58d898d5e3ba5cb843e0ebd377c8a461464748586049afbd9321f51", size = 11369556, upload-time = "2026-03-26T16:26:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/92/4c/6d2f8f34bc6d502ab778c9345a4a936a72ae113de11329c1764bb1f204f6/ty-0.0.26-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:384807bbcb7d7ce9b97ee5aaa6417a8ae03ccfb426c52b08018ca62cf60f5430", size = 11085679, upload-time = "2026-03-26T16:27:21.746Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f4/f3f61c203bc980dd9bba0ba7ed3c6e81ddfd36b286330f9487c2c7d041aa/ty-0.0.26-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2c766a94d79b4f82995d41229702caf2d76e5c440ec7e543d05c70e98bf8ab", size = 10900581, upload-time = "2026-03-26T16:27:24.39Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fd/3ca1b4e4bdd129829e9ce78677e0f8e0f1038a7702dccecfa52f037c6046/ty-0.0.26-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f41ac45a0f8e3e8e181508d863a0a62156341db0f624ffd004b97ee550a9de80", size = 10294401, upload-time = "2026-03-26T16:27:03.999Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/4ee3d8c3f90e008843795c765cb8bb245f188c23e5e5cc612c7697406fba/ty-0.0.26-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:73eb8327a34d529438dfe4db46796946c4e825167cbee434dc148569892e435f", size = 10351469, upload-time = "2026-03-26T16:27:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b1/9fb154ade65906d4148f0b999c4a8257c2a34253cb72e15d84c1f04a064e/ty-0.0.26-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4bb53a79259516535a1b55f613ba1619e9c666854946474ca8418c35a5c4fd60", size = 10529488, upload-time = "2026-03-26T16:27:01.378Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b02b03b1862e27b64143db65946d68b138160a5b6bfea193bee0b8bbc34/ty-0.0.26-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2f0e75edc1aeb1b4b84af516c7891f631254a4ca3dcd15e848fa1e061e1fe9da", size = 10999015, upload-time = "2026-03-26T16:27:34.636Z" }, + { url = "https://files.pythonhosted.org/packages/21/16/0a56b8667296e2989b9d48095472d98ebf57a0006c71f2a101bbc62a142d/ty-0.0.26-py3-none-win32.whl", hash = "sha256:943c998c5523ed6b519c899c0c39b26b4c751a9759e460fb964765a44cde226f", size = 9912378, upload-time = "2026-03-26T16:27:08.999Z" }, + { url = "https://files.pythonhosted.org/packages/60/c2/fef0d4bba9cd89a82d725b3b1a66efb1b36629ecf0fb1d8e916cb75b8829/ty-0.0.26-py3-none-win_amd64.whl", hash = "sha256:19c856d343efeb1ecad8ee220848f5d2c424daf7b2feda357763ad3036e2172f", size = 10863737, upload-time = "2026-03-26T16:27:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/4d/05/888ebcb3c4d3b6b72d5d3241fddd299142caa3c516e6d26a9cd887dfed3b/ty-0.0.26-py3-none-win_arm64.whl", hash = "sha256:2cde58ccffa046db1223dc28f3e7d4f2c7da8267e97cc5cd186af6fe85f1758a", size = 10285408, upload-time = "2026-03-26T16:27:16.432Z" }, ] [[package]] name = "typer" -version = "0.23.2" +version = "0.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -3010,9 +3020,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/93d16574e66dfe4c2284ffdaca4b0320ade32858cb2cc586c8dd79f127c5/typer-0.23.2.tar.gz", hash = "sha256:a99706a08e54f1aef8bb6a8611503808188a4092808e86addff1828a208af0de", size = 120162, upload-time = "2026-02-16T18:52:40.354Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2c/dee705c427875402200fe779eb8a3c00ccb349471172c41178336e9599cc/typer-0.23.2-py3-none-any.whl", hash = "sha256:e9c8dc380f82450b3c851a9b9d5a0edf95d1d6456ae70c517d8b06a50c7a9978", size = 56834, upload-time = "2026-02-16T18:52:39.308Z" }, + { 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" }, ] [[package]] @@ -3065,16 +3075,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.42.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/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { 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" }, ] [[package]]