From 8beb999702cd5f3d50ef1d06c0ab23866e964294 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Fri, 30 Oct 2026 13:06:41 +0530 Subject: [PATCH 1/7] feat: add auth0-ai-mcp package --- .github/dependabot.yml | 8 + .github/workflows/test.yml | 69 + packages/auth0-ai-mcp/.gitignore | 30 + packages/auth0-ai-mcp/.ruff.toml | 21 + packages/auth0-ai-mcp/.shiprc | 7 + packages/auth0-ai-mcp/.snyk | 17 + packages/auth0-ai-mcp/.version | 1 + packages/auth0-ai-mcp/CHANGELOG.md | 1 + packages/auth0-ai-mcp/CONTRIBUTING.md | 31 + packages/auth0-ai-mcp/LICENSE | 56 + packages/auth0-ai-mcp/NOTICE | 18 + packages/auth0-ai-mcp/README.md | 115 ++ packages/auth0-ai-mcp/poetry.lock | 1717 +++++++++++++++++ packages/auth0-ai-mcp/pyproject.toml | 28 + .../auth0-ai-mcp/src/auth0_ai_mcp/__init__.py | 84 + .../auth0-ai-mcp/src/auth0_ai_mcp/client.py | 188 ++ .../auth0-ai-mcp/src/auth0_ai_mcp/errors.py | 40 + .../src/auth0_ai_mcp/middleware.py | 219 +++ .../src/auth0_ai_mcp/principal.py | 89 + .../auth0-ai-mcp/src/auth0_ai_mcp/py.typed | 0 .../src/auth0_ai_mcp/request_context.py | 94 + .../src/auth0_ai_mcp/request_scoped_client.py | 50 + .../auth0-ai-mcp/src/auth0_ai_mcp/scopes.py | 69 + .../src/auth0_ai_mcp/utils/__init__.py | 0 .../auth0_ai_mcp/utils/_internal/__init__.py | 1 + .../auth0_ai_mcp/utils/_internal/exchange.py | 43 + .../auth0_ai_mcp/utils/_internal/headers.py | 19 + .../utils/_internal/mcp_session.py | 30 + .../utils/_internal/scope_input.py | 23 + .../auth0_ai_mcp/utils/_internal/telemetry.py | 21 + .../src/auth0_ai_mcp/utils/_internal/urls.py | 68 + packages/auth0-ai-mcp/tests/test_client.py | 545 ++++++ .../auth0-ai-mcp/tests/test_middleware.py | 687 +++++++ packages/auth0-ai-mcp/tests/test_principal.py | 135 ++ .../tests/test_request_context.py | 64 + packages/auth0-ai-mcp/tests/test_scopes.py | 152 ++ 36 files changed, 4740 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 packages/auth0-ai-mcp/.gitignore create mode 100644 packages/auth0-ai-mcp/.ruff.toml create mode 100644 packages/auth0-ai-mcp/.shiprc create mode 100644 packages/auth0-ai-mcp/.snyk create mode 100644 packages/auth0-ai-mcp/.version create mode 100644 packages/auth0-ai-mcp/CHANGELOG.md create mode 100644 packages/auth0-ai-mcp/CONTRIBUTING.md create mode 100644 packages/auth0-ai-mcp/LICENSE create mode 100644 packages/auth0-ai-mcp/NOTICE create mode 100644 packages/auth0-ai-mcp/README.md create mode 100644 packages/auth0-ai-mcp/poetry.lock create mode 100644 packages/auth0-ai-mcp/pyproject.toml create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/__init__.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/client.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/errors.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/principal.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/py.typed create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/request_context.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/request_scoped_client.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/scopes.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/__init__.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/__init__.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/exchange.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/headers.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/mcp_session.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/telemetry.py create mode 100644 packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/urls.py create mode 100644 packages/auth0-ai-mcp/tests/test_client.py create mode 100644 packages/auth0-ai-mcp/tests/test_middleware.py create mode 100644 packages/auth0-ai-mcp/tests/test_principal.py create mode 100644 packages/auth0-ai-mcp/tests/test_request_context.py create mode 100644 packages/auth0-ai-mcp/tests/test_scopes.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0e8ea90..74fc6cc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,11 @@ updates: - "/.github/actions/*" schedule: interval: "daily" + + - package-ecosystem: "pip" + directory: "/packages/auth0-ai-mcp" + schedule: + interval: "daily" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6ac281b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,69 @@ +name: Build and Test + +on: + merge_group: + pull_request: + types: + - opened + - synchronize + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + test: + name: test (${{ matrix.package }}, py${{ matrix.python-version }}) + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ matrix.package }} + strategy: + matrix: + package: [packages/auth0-ai-mcp] + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 + with: + version: "2.4.1" + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ./${{ matrix.package }}/.venv + key: venv-${{ runner.os }}-${{ matrix.package }}-${{ matrix.python-version }}-${{ hashFiles(format('{0}/poetry.lock', matrix.package)) }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root + + - name: Install package + run: poetry install --no-interaction + + - name: Run tests with pytest + run: | + poetry run pytest -v + + - name: Run ruff linting + run: | + poetry run ruff check . diff --git a/packages/auth0-ai-mcp/.gitignore b/packages/auth0-ai-mcp/.gitignore new file mode 100644 index 0000000..9af0e1b --- /dev/null +++ b/packages/auth0-ai-mcp/.gitignore @@ -0,0 +1,30 @@ +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +#Environments +.env +.venv +.venv-* +env/ + +#Session Cache +.sessions_cache +.DS_Store + +#Build files +dist + +#Test and lint caches +.pytest_cache/ +.ruff_cache/ +.coverage +coverage.xml + +# IDE +.idea/ + +# AI Tools +.claude/ diff --git a/packages/auth0-ai-mcp/.ruff.toml b/packages/auth0-ai-mcp/.ruff.toml new file mode 100644 index 0000000..b9a4b9d --- /dev/null +++ b/packages/auth0-ai-mcp/.ruff.toml @@ -0,0 +1,21 @@ +line-length = 100 +target-version = "py310" + +[lint] +preview = true +explicit-preview-rules = true # only the preview rules selected below, such as PLC0415 +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "S", # bandit (security) + "PLC0415", # import outside top level +] +ignore = ["E501", "B904"] # Line too long, Exception handling without from + +[lint.per-file-ignores] +"tests/*" = ["S101", "S105", "S106"] # Allow assert and ignore hardcoded password warnings in test files diff --git a/packages/auth0-ai-mcp/.shiprc b/packages/auth0-ai-mcp/.shiprc new file mode 100644 index 0000000..de867c4 --- /dev/null +++ b/packages/auth0-ai-mcp/.shiprc @@ -0,0 +1,7 @@ +{ + "files": { + ".version": [], + "pyproject.toml": ["version = \"{MAJOR}.{MINOR}.{PATCH}{PRERELEASE_LABEL}{PRERELEASE_COUNTER}\""] + }, + "prefixVersion": false +} diff --git a/packages/auth0-ai-mcp/.snyk b/packages/auth0-ai-mcp/.snyk new file mode 100644 index 0000000..69a0a4f --- /dev/null +++ b/packages/auth0-ai-mcp/.snyk @@ -0,0 +1,17 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.12.0 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + "snyk:lic:pip:certifi:MPL-2.0": + - '*': + reason: "Accepting certifi's MPL-2.0 license for now" + expires: "2030-12-31T23:59:59Z" + "snyk:lic:pip:jwcrypto:LGPL-3.0": + - '*': + reason: "Accepting jwcrypto's LGPL-3.0 license for now" + expires: "2030-12-31T23:59:59Z" + "snyk:lic:pip:cffi:Unknown": + - '*': + reason: "Accepting the Unknown license for now" + expires: "2030-12-31T23:59:59Z" +patch: {} diff --git a/packages/auth0-ai-mcp/.version b/packages/auth0-ai-mcp/.version new file mode 100644 index 0000000..cdf2991 --- /dev/null +++ b/packages/auth0-ai-mcp/.version @@ -0,0 +1 @@ +1.0.0b0 diff --git a/packages/auth0-ai-mcp/CHANGELOG.md b/packages/auth0-ai-mcp/CHANGELOG.md new file mode 100644 index 0000000..420e6f2 --- /dev/null +++ b/packages/auth0-ai-mcp/CHANGELOG.md @@ -0,0 +1 @@ +# Change Log diff --git a/packages/auth0-ai-mcp/CONTRIBUTING.md b/packages/auth0-ai-mcp/CONTRIBUTING.md new file mode 100644 index 0000000..15a1600 --- /dev/null +++ b/packages/auth0-ai-mcp/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contribution + +Please read [Auth0's contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md). + +## Environment setup + +- Python 3.10 or newer and Poetry +- Run `poetry install` to install dependencies +- Follow the local development steps below to get started + +## Local development + +- `poetry install`: install dependencies +- `poetry run pytest`: run unit tests +- `poetry run ruff check .`: check code style and lint errors +- `poetry build`: build the package + +## Testing + +### Adding tests + +Every change should be accompanied by a test. + +### Running tests + +Run unit tests and the linter before opening a PR: + +```bash +poetry run pytest +poetry run ruff check . +``` \ No newline at end of file diff --git a/packages/auth0-ai-mcp/LICENSE b/packages/auth0-ai-mcp/LICENSE new file mode 100644 index 0000000..86370bd --- /dev/null +++ b/packages/auth0-ai-mcp/LICENSE @@ -0,0 +1,56 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +Copyright 2026 Okta, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.You may obtain a copy of the License at: +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. \ No newline at end of file diff --git a/packages/auth0-ai-mcp/NOTICE b/packages/auth0-ai-mcp/NOTICE new file mode 100644 index 0000000..c224642 --- /dev/null +++ b/packages/auth0-ai-mcp/NOTICE @@ -0,0 +1,18 @@ +============================================================= + Declared License +============================================================= +Apache-2.0 +============================================================= + First Party Licenses + +Copyright 2026 Okta, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +============================================================= + Dependencies +============================================================= + +This software has dependencies that do not require attribution notices in this file as none of them are bundled with this distribution. +These dependencies are installed separately by package managers. For a complete list of dependencies and their licenses, please refer to the +pyproject.toml file for this package. \ No newline at end of file diff --git a/packages/auth0-ai-mcp/README.md b/packages/auth0-ai-mcp/README.md new file mode 100644 index 0000000..8d35d5a --- /dev/null +++ b/packages/auth0-ai-mcp/README.md @@ -0,0 +1,115 @@ +The `auth0-ai-mcp` library secures [Model Context Protocol (MCP)](https://modelcontextprotocol.io) servers with Auth0, built on [`auth0-api-python`](https://github.com/auth0/auth0-api-python). + +An MCP server is just another resource server. This library ships no MCP framework of its own. It verifies the incoming bearer token, runs the On-Behalf-Of token exchange for downstream API calls with an optional cache in front, and exposes the verified caller to tool code. Each framework gets a thin adapter that wires this into its request pipeline. + +![Release](https://img.shields.io/pypi/v/auth0-ai-mcp) ![Downloads](https://img.shields.io/pypi/dw/auth0-ai-mcp) [![License](https://img.shields.io/:license-Apache--2.0-blue.svg?style=flat)](https://opensource.org/licenses/Apache-2.0) + +🚀 [Getting Started](#getting-started) - 💬 [Feedback](#feedback) + +## How it works + +1. **Verify** - every incoming bearer token is checked before the MCP handler runs. +2. **Context** - the verified caller is available to tool code. +3. **Scopes** - tools can require scopes on the caller's token. +4. **On-Behalf-Of** - exchange the caller's token for a downstream token. +5. **Token Vault** - exchange the caller's token for a federated provider token. +6. **Client Credentials** - get a token for the server itself. + +## Getting Started + +### 1. Install + +```bash +pip install auth0-ai-mcp +``` + +Or with Poetry: + +```bash +poetry add auth0-ai-mcp +``` + +Python 3.10 or later is required. + +### 2. Build the client + +```python +from auth0_ai_mcp import create_mcp_client + +client = create_mcp_client( + domain="your-tenant.auth0.com", + audience="https://your-mcp-server/mcp", + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", +) +``` + +> [!IMPORTANT] +> `audience` must be the public URL of your MCP server. It is also the API identifier you register in Auth0, so a token issued for any other value is rejected with a `401`. + +`client_id` and `client_secret` are only needed for token exchanges. + +### 3. Wire an adapter + +The ASGI adapter wraps any ASGI app and handles all auth work before the MCP handler runs: + +```python +from auth0_ai_mcp.middleware import Auth0AsgiMiddleware + +secured_app = Auth0AsgiMiddleware( + app, + mcp_client=client, +) +``` + + +### 4. Write a gated tool + +```python +from auth0_ai_mcp import require_caller_mcp_client, with_required_scopes + +@with_required_scopes("read:weather") +async def get_weather(location: str) -> dict: + auth0 = require_caller_mcp_client() + token = await auth0.get_token_on_behalf_of( + audience="https://weather-api/", + scope="read:weather", + ) + # call the downstream API with token["access_token"] +``` + +`with_required_scopes` raises before the tool body runs if the caller's token is missing a listed scope. `require_caller_mcp_client` reads the request-scoped client from the `contextvar` set by the adapter. + +## Feedback + +### Contributing + +We appreciate feedback and contribution to this repo! Before you get started, please read the following: + +- [Auth0's general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md) +- [Auth0's code of conduct guidelines](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md) +- [This package's contribution guide](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/CONTRIBUTING.md) + +### Raise an issue + +To provide feedback or report a bug, please [raise an issue on our issue tracker](https://github.com/auth0/auth0-ai-python/issues). + +## Vulnerability Reporting + +Please do not report security vulnerabilities on the public GitHub issue tracker. The [Responsible Disclosure Program](https://auth0.com/responsible-disclosure-policy) details the procedure for disclosing security issues. + +## What is Auth0? + +

+ + + + Auth0 Logo + +

+

+ Auth0 is an easy to implement, adaptable authentication and authorization platform. To learn more checkout Why Auth0? +

+

+ This project is licensed under the Apache 2.0 license. See the LICENSE file for more info. +

diff --git a/packages/auth0-ai-mcp/poetry.lock b/packages/auth0-ai-mcp/poetry.lock new file mode 100644 index 0000000..a8f1def --- /dev/null +++ b/packages/auth0-ai-mcp/poetry.lock @@ -0,0 +1,1717 @@ +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "ada-url" +version = "1.32.0" +description = "URL parser and manipulator based on the WHAT WG URL standard" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "ada_url-1.32.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:c4aecd49bea562c28e7b2fe03257e88cee8f5af9f8a02c21f4deabeb341d1399"}, + {file = "ada_url-1.32.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:3ad2c40dcf5ecf4f72ef2fb8c5edcecbaf5f1ea19418b416bd2c1785c35d7d32"}, + {file = "ada_url-1.32.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40e3e88079bfddf303d72165fbc8453f10d0f9431753e43345135305cbfad044"}, + {file = "ada_url-1.32.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9f5e11b8939bc66b8615262396fc0a630e5a796a6702bda253121c30ee873f9"}, + {file = "ada_url-1.32.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5969f420ccfc4439233fca31cdcd5b0d1ba1cb165b5fb49767891ab3b247e3a0"}, + {file = "ada_url-1.32.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a64b58055b468d4d2d6f79969da25157b420650e24eb3abb5732005f2657cc83"}, + {file = "ada_url-1.32.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ab3576f6d08a8dceac382bffb570b0a176e6514f9ad829929ce58c16a68d0d"}, + {file = "ada_url-1.32.0-cp310-cp310-win_amd64.whl", hash = "sha256:5d7288aa9d8b46b4aa2933142a3ca1c512c0c60f2ac521b9b9e3edc7522be2e3"}, + {file = "ada_url-1.32.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:2a8a5535e103e4a8b5df004bf7bdc9972a7c91bb1b216c9fb13d7553584e8e48"}, + {file = "ada_url-1.32.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:1c0e0d09ba016f5ceec31fd3146695977f398ac6138dbb3fb71b8dd6dd5939d3"}, + {file = "ada_url-1.32.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:df220a146fb76a222b5517ece6db0435809ab4d084fc6f7c6d1c33306bc42fc6"}, + {file = "ada_url-1.32.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac765be12f6725445c42830ada8cb9a771ac6a53775f994e0b8127b1ec7a8e5"}, + {file = "ada_url-1.32.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba92322e725ca54c7e363bfd3ea2e037c00a0328816d569e7aca5f0ba10cfb89"}, + {file = "ada_url-1.32.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b81e4dc35a0e8b66033858f736cdc6469c3b7bc3007ad6983955d92cc31dfde4"}, + {file = "ada_url-1.32.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:47bbfe5ac26522008233dc18c8ad781684e03e3817c0b8af2038426b888a38f6"}, + {file = "ada_url-1.32.0-cp311-cp311-win_amd64.whl", hash = "sha256:647624cd77e6a8d825e678e567d7c8665c0e81e9a497ddd37251803b040ae434"}, + {file = "ada_url-1.32.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:a3b5afe0ad3c9123ecc504f12aa04ee3a281b4edd34333488b8a6d4a901a740d"}, + {file = "ada_url-1.32.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:6e7712dd34f674c372d9315cd57fc1e342437090a19eaa6aa775cdd473869330"}, + {file = "ada_url-1.32.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5381328ee3fb1290538e621b0dfe1f842dbbfc7bb06d1e4f0eb10d3ce24474a7"}, + {file = "ada_url-1.32.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ab2bc09336ad8ccffca6ea86d149430413007baf8b9684fad8a50ec270391ec"}, + {file = "ada_url-1.32.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aee8ebca59d5627eecf22fd73889bb0f4415e64203d14c9557399c20c0aff549"}, + {file = "ada_url-1.32.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c17dbcda7da6a4122b14d9cb612af07b78eaf2ba414d7f9069fd6e8723ed0ffa"}, + {file = "ada_url-1.32.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f5d5933bf8bd017edd5ad2c5c8ecddfb348b5642c49aad1aa565c8ff3ca02e89"}, + {file = "ada_url-1.32.0-cp312-cp312-win_amd64.whl", hash = "sha256:26b2ab11506a6fde3911789664ae5583b02422a1fb21d15ca4bf2ab2fe9efebd"}, + {file = "ada_url-1.32.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:18b14441d9cb39680ef24dacf49d9266ce3326b14a11df890fee39706fe726fd"}, + {file = "ada_url-1.32.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3c83f01a2e2e3cb64532f59cf1cee7960a44f393888ab98b4f8d56629b2e0fbe"}, + {file = "ada_url-1.32.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f6efa3421e20d1cd5d5d849b22282b676a4cff256228486f862dce59ade11d9"}, + {file = "ada_url-1.32.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b50e107ef029e27cf95efdfde942dc994cef18c33140a340f2464d4a34aae9d"}, + {file = "ada_url-1.32.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9320cf887dac87dd81e9d571921c79d2b53922c34c25a579861d092c129eff1d"}, + {file = "ada_url-1.32.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa1c9b5a41da603dedb1d5bafac6ea4df449f88fea482e7e845494fa250a3a1c"}, + {file = "ada_url-1.32.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:387c8578f38e21fc25eeb791df22644b22321872d4cfc6e9158102cf240c6224"}, + {file = "ada_url-1.32.0-cp313-cp313-win_amd64.whl", hash = "sha256:49ed3336a72ac0193dfe98b9addc231bdac2c052cc752f4c404b4b780afa661a"}, + {file = "ada_url-1.32.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2553ba679830d32480715bc048fb5b5ea94e42006506eb8ad09c12fb2e1d19c8"}, + {file = "ada_url-1.32.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:251a26590edac8063d1b0d9eb4ed81ca47ad9c0d613146f337443f66bc07c5c3"}, + {file = "ada_url-1.32.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2a7f637309414fa9355f714a2c1268427b84fdb1cb3bdf2f76e6f59723651019"}, + {file = "ada_url-1.32.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea5031dbad3b46fd93e105ca869ca5b0e630b6d2c4a929eb92f3508cfd6b0d5"}, + {file = "ada_url-1.32.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8b5b5541e6f6f4eefdfdf823e2892d15f173c638312d6ca0f2243fb38c70230"}, + {file = "ada_url-1.32.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:314534a62f63fefd53f43696780260f8b23326004aa641d71ef74a33c6b064bd"}, + {file = "ada_url-1.32.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a9acfd240f9c20c1ed2052ad9a8412f30ad6b03ed4a9e965fb56b871c2fdf7b5"}, + {file = "ada_url-1.32.0-cp314-cp314-win_amd64.whl", hash = "sha256:eea67edc7c7dcb784d7e43149c5d4bfc7f394af8a1cbfc8a14e6ec317b4bf8e4"}, + {file = "ada_url-1.32.0.tar.gz", hash = "sha256:ceefeb2e89d6e007ed58caa461e802b7a0f39fbbbdd7e39a4511fc1e3a6f9777"}, +] + +[package.dependencies] +cffi = "*" + +[[package]] +name = "annotated-types" +version = "0.8.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, + {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, +] + +[[package]] +name = "anyio" +version = "4.15.1" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101"}, + {file = "anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.16.0", markers = "python_version < \"3.15\""} + +[package.extras] +trio = ["trio (>=0.32.0)"] + +[[package]] +name = "attrs" +version = "26.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, +] + +[[package]] +name = "auth0-api-python" +version = "1.0.0b10" +description = "SDK for verifying access tokens and securing APIs with Auth0, using Authlib." +optional = false +python-versions = ">=3.9.2" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +ada-url = {version = "^1.30.0", markers = "python_version >= \"3.10\""} +authlib = "^1.0" +cryptography = ">=43.0.1" +httpx = "^0.28.1" +jwcrypto = "^1.5.7" +requests = "^2.31.0" + +[package.source] +type = "git" +url = "https://github.com/auth0/auth0-api-python.git" +reference = "1fe1a417e867d417b4bd44c5984fe002f11149f9" +resolved_reference = "1fe1a417e867d417b4bd44c5984fe002f11149f9" + +[[package]] +name = "authlib" +version = "1.9.1" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "authlib-1.9.1-py2.py3-none-any.whl", hash = "sha256:8b9be8b1e5174dcbf5520e984f1a22dc2edae5b6e40a61d4cedbf86b97e9e69b"}, + {file = "authlib-1.9.1.tar.gz", hash = "sha256:5c7d9848f47cac340f060f76ae0bc09b59f5c8e89cc1bb032d4370a0c55b5974"}, +] + +[package.dependencies] +cryptography = ">=45.0.1" +joserfc = ">=1.6.8" + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] + +[[package]] +name = "certifi" +version = "2026.10.19" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b"}, + {file = "certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae"}, +] + +[[package]] +name = "cffi" +version = "2.1.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be"}, + {file = "cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9"}, + {file = "cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41"}, + {file = "cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa"}, + {file = "cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3"}, + {file = "cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0"}, + {file = "cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735"}, + {file = "cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e"}, + {file = "cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a"}, + {file = "cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7"}, + {file = "cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac"}, + {file = "cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d"}, + {file = "cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13"}, + {file = "cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c"}, + {file = "cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48"}, + {file = "cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f"}, + {file = "cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4"}, + {file = "cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e"}, + {file = "cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7"}, + {file = "cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac"}, + {file = "cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960"}, + {file = "cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5"}, + {file = "cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66"}, + {file = "cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3"}, + {file = "cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692"}, + {file = "cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be"}, +] +markers = {dev = "platform_python_implementation != \"PyPy\""} + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-win32.whl", hash = "sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-win_arm64.whl", hash = "sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8"}, + {file = "charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6"}, + {file = "charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3"}, +] + +[[package]] +name = "click" +version = "8.5.0" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "sys_platform != \"emscripten\"" +files = [ + {file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"}, + {file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["main", "dev"] +files = [ + {file = "cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a"}, + {file = "cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959"}, + {file = "cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b"}, + {file = "cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648"}, + {file = "cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3"}, + {file = "cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6"}, + {file = "cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149"}, + {file = "cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf"}, + {file = "cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239"}, + {file = "cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558"}, + {file = "cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e"}, + {file = "cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b"}, + {file = "cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] +markers = {dev = "sys_platform != \"emscripten\""} + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpcore2" +version = "2.13.0" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "sys_platform != \"emscripten\"" +files = [ + {file = "httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e"}, + {file = "httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db"}, +] + +[package.dependencies] +h11 = ">=0.16" +truststore = ">=0.10" + +[package.extras] +asyncio = ["anyio (>=4.5.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.34.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "httpx2" +version = "2.13.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a"}, + {file = "httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44"}, +] + +[package.dependencies] +anyio = {version = ">=4.10", markers = "sys_platform != \"emscripten\""} +httpcore2 = {version = "2.13.0", markers = "sys_platform != \"emscripten\""} +httpx2-jsfetch = {version = "*", markers = "sys_platform == \"emscripten\" and python_version >= \"3.12\""} +idna = ">=3.18" +truststore = {version = ">=0.10", markers = "sys_platform != \"emscripten\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version < \"3.13\""} + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.2) ; platform_python_implementation != \"CPython\""] +cli = ["click (>=8.4.2)", "pygments (==2.*)", "rich (>=10,<16)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +ws = ["wsproto (>=1.2)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version <= \"3.13\""] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +description = "httpx2 transports for Emscripten/Pyodide, backed by the JavaScript fetch API." +optional = false +python-versions = ">=3.12" +groups = ["dev"] +markers = "sys_platform == \"emscripten\" and python_version >= \"3.12\"" +files = [ + {file = "httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32"}, + {file = "httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60"}, +] + +[[package]] +name = "idna" +version = "3.19" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, + {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, +] + +[package.extras] +all = ["coverage (>=7.10.0)", "hypothesis (>=6.141.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.16.0)", "ty (>=0.0.37)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "joserfc" +version = "1.7.5" +description = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159"}, + {file = "joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea"}, +] + +[package.dependencies] +cryptography = ">=45.0.1" + +[package.extras] +drafts = ["pycryptodome"] + +[[package]] +name = "jsonschema" +version = "4.26.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.3.6" +referencing = ">=0.28.4" +rpds-py = ">=0.25.0" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + +[[package]] +name = "jwcrypto" +version = "1.6.1" +description = "Implementation of JOSE Web standards" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jwcrypto-1.6.1-py3-none-any.whl", hash = "sha256:77e856818e514cb3d64a1043b6885a7d52f5cc37773f5e944de02103c8d3a54f"}, + {file = "jwcrypto-1.6.1.tar.gz", hash = "sha256:a1e1570da5c2e35dbcd375ec1d2891a24de8eb35ee2c1fe8e91978efb72cfe90"}, +] + +[package.dependencies] +cryptography = ">=49.0.0" +typing_extensions = ">=4.5.0" + +[[package]] +name = "mcp" +version = "2.3.0" +description = "Model Context Protocol SDK" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377"}, + {file = "mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8"}, +] + +[package.dependencies] +anyio = [ + {version = ">=4.9", markers = "python_version < \"3.14\""}, + {version = ">=4.10", markers = "python_version >= \"3.14\""}, +] +httpx2 = ">=2.10.0" +jsonschema = ">=4.20.0" +mcp-types = "2.3.0" +opentelemetry-api = ">=1.28.0" +pydantic = ">=2.12.0" +pyjwt = {version = ">=2.10.1", extras = ["crypto"]} +python-multipart = ">=0.0.9" +pywin32 = {version = ">=311", markers = "sys_platform == \"win32\""} +sse-starlette = ">=3.0.0" +starlette = [ + {version = ">=0.27", markers = "python_version < \"3.14\""}, + {version = ">=0.48.0", markers = "python_version >= \"3.14\""}, +] +typing-extensions = ">=4.13.0" +typing-inspection = ">=0.4.1" +uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} + +[package.extras] +cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] +rich = ["rich (>=13.9.4)"] + +[[package]] +name = "mcp-types" +version = "2.3.0" +description = "Model Context Protocol wire types" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d"}, + {file = "mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362"}, +] + +[package.dependencies] +pydantic = ">=2.12.0" +typing-extensions = ">=4.13.0" + +[[package]] +name = "opentelemetry-api" +version = "1.45.2" +description = "OpenTelemetry Python API" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "opentelemetry_api-1.45.2-py3-none-any.whl", hash = "sha256:2cbd737377e710ee740d261c94e93fa0f08e6d037c18f17513cc0a2d94479a34"}, + {file = "opentelemetry_api-1.45.2.tar.gz", hash = "sha256:d7b0008e21e5fd61cbe7a7f34ca3b6905a78ab677d9bc395e299359c75cda6b4"}, +] + +[package.dependencies] +typing-extensions = ">=4.5.0" + +[[package]] +name = "packaging" +version = "26.3" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"}, + {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] +markers = {main = "implementation_name != \"PyPy\"", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""} + +[[package]] +name = "pydantic" +version = "2.13.5" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73"}, + {file = "pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.46.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6"}, + {file = "pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1"}, + {file = "pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069"}, + {file = "pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d"}, + {file = "pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f"}, + {file = "pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8"}, + {file = "pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d"}, + {file = "pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084"}, + {file = "pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0"}, + {file = "pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575"}, + {file = "pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355"}, + {file = "pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f"}, + {file = "pydantic_core-2.46.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed"}, + {file = "pydantic_core-2.46.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13"}, + {file = "pydantic_core-2.46.5-cp39-cp39-win32.whl", hash = "sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7"}, + {file = "pydantic_core-2.46.5-cp39-cp39-win_amd64.whl", hash = "sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266"}, + {file = "pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pygments" +version = "2.21.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"}, + {file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyjwt" +version = "2.15.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193"}, + {file = "pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} +typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] + +[[package]] +name = "pytest" +version = "9.2.0" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588"}, + {file = "pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=2" +packaging = ">=24" +pluggy = ">=1.6,<2" +pygments = ">=2.15" +tomli = {version = ">=2", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "asynctest ; python_version < \"3.11\"", "attrs (>=23.1)", "coverage (>=7.5)", "hypothesis (>=6.75)", "mock", "numpy (>=1.26)", "pexpect (>=4.9)", "pytest-xdist (>=3.5)", "pyyaml", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "1.4.1" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc"}, + {file = "pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b"}, +] + +[package.dependencies] +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.4,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc"}, + {file = "python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-multipart" +version = "0.0.32" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"}, + {file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"}, +] + +[[package]] +name = "pywin32" +version = "312" +description = "Python for Windows Extensions" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"}, + {file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"}, + {file = "pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd"}, + {file = "pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c"}, + {file = "pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a"}, + {file = "pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47"}, + {file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"}, + {file = "pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc"}, + {file = "pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950"}, + {file = "pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c"}, + {file = "pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9"}, + {file = "pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831"}, + {file = "pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b"}, + {file = "pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e"}, + {file = "pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa"}, + {file = "pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed"}, + {file = "pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5"}, + {file = "pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9"}, + {file = "pywin32-312-cp39-cp39-win32.whl", hash = "sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5"}, + {file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"}, + {file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"}, +] + +[[package]] +name = "referencing" +version = "0.37.0" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + +[[package]] +name = "requests" +version = "2.34.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + +[[package]] +name = "rpds-py" +version = "0.30.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "python_version < \"3.14\"" +files = [ + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.11" +groups = ["dev"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7"}, + {file = "rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804"}, + {file = "rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0"}, + {file = "rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96"}, + {file = "rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223"}, + {file = "rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb"}, + {file = "rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e"}, + {file = "rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2"}, + {file = "rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13"}, + {file = "rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826"}, + {file = "rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4"}, +] + +[[package]] +name = "ruff" +version = "0.17.3" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4"}, + {file = "ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11"}, + {file = "ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29"}, + {file = "ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad"}, + {file = "ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1"}, + {file = "ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f"}, + {file = "ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532"}, + {file = "ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef"}, + {file = "ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e"}, + {file = "ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5"}, + {file = "ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a"}, + {file = "ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb"}, + {file = "ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2"}, + {file = "ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe"}, + {file = "ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca"}, + {file = "ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24"}, + {file = "ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9"}, + {file = "ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9"}, +] + +[[package]] +name = "sse-starlette" +version = "3.4.11" +description = "SSE plugin for Starlette" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453"}, + {file = "sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade"}, +] + +[package.dependencies] +anyio = ">=4.7.0" +starlette = ">=0.49.1" + +[package.extras] +daphne = ["daphne (>=4.2.0)"] +examples = ["fastapi (>=0.115.12)", "pydantic (>=2)", "uvicorn (>=0.34.0)"] +examples-db = ["aiosqlite (>=0.21.0)", "sqlalchemy[asyncio] (>=2.0.41)"] +granian = ["granian (>=2.3.1)"] +uvicorn = ["uvicorn (>=0.34.0)"] + +[[package]] +name = "starlette" +version = "1.7.0" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +markers = "python_version < \"3.14\"" +files = [ + {file = "starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e"}, + {file = "starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d"}, +] + +[package.dependencies] +anyio = ">=4.0.0,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "opentelemetry-api", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "starlette" +version = "1.9.0" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.11" +groups = ["main", "dev"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc"}, + {file = "starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318"}, +] + +[package.dependencies] +anyio = ">=4.0.0,<5" + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "opentelemetry-api", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "tomli" +version = "2.4.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, +] + +[[package]] +name = "truststore" +version = "0.10.4" +description = "Verify certificates using native system trust stores" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "sys_platform != \"emscripten\"" +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, + {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, +] + +[package.dependencies] +typing-extensions = ">=4.15.0" + +[[package]] +name = "urllib3" +version = "2.8.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3"}, + {file = "urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "uvicorn" +version = "0.52.4" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "sys_platform != \"emscripten\"" +files = [ + {file = "uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1"}, + {file = "uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["httptools (>=0.8.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=13.0)"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10,<4.0" +content-hash = "6b895636a3b5b36034da2aae7fa0ec05bc2d83d57937ca78ccfea13ae42f9d24" diff --git a/packages/auth0-ai-mcp/pyproject.toml b/packages/auth0-ai-mcp/pyproject.toml new file mode 100644 index 0000000..1459129 --- /dev/null +++ b/packages/auth0-ai-mcp/pyproject.toml @@ -0,0 +1,28 @@ +[tool.poetry] +name = "auth0-ai-mcp" +version = "1.0.0b0" +description = "Auth0 authentication helpers for MCP servers, built on auth0-api-python." +authors = ["Auth0 "] +license = "Apache-2.0" +readme = "README.md" +packages = [{ include = "auth0_ai_mcp", from = "src" }] + +[tool.poetry.dependencies] +python = ">=3.10,<4.0" +# Pinned by SHA until a core release includes the OBO, M2M and Token Vault caching, the malformed-token fix and client_info. +auth0-api-python = { git = "https://github.com/auth0/auth0-api-python.git", rev = "1fe1a417e867d417b4bd44c5984fe002f11149f9" } +starlette = ">=0.37.0" + +[tool.poetry.group.dev.dependencies] +mcp = "^2.2.0" +python-dotenv = "^1.2.3" +pytest = "^9.0" +pytest-asyncio = ">=0.23" +ruff = ">=0.6" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/__init__.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/__init__.py new file mode 100644 index 0000000..2c09861 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/__init__.py @@ -0,0 +1,84 @@ +"""Auth0 authentication helpers for MCP servers. + +Verifies Auth0 access tokens, builds the caller, enforces scopes, and exchanges tokens +(On-Behalf-Of, client credentials, Token Vault) with an optional `token_store` for caching. +It owns no MCP framework, so each framework gets a thin adapter around it. +""" + +from __future__ import annotations + +from auth0_api_python import ( + AbstractTokenStore, + ApiClient, + ApiClientOptions, + GetAccessTokenForConnectionError, + GetClientCredentialsTokenError, + IndexedTokenStore, + TokenIndexMember, + TokenSet, + VerifiedToken, +) +from auth0_api_python.errors import ( + ApiError, + AuthServerUnavailableError, + ConfigurationError, + GetTokenByExchangeProfileError, + MissingOrganizationError, + MissingRequiredArgumentError, + OrganizationNotAllowedError, + VerifyAccessTokenError, +) + +from .client import McpServerClient, create_mcp_client +from .errors import ( + DownscopedTokenError, + InsufficientScopeError, + UnauthenticatedError, +) +from .principal import Principal, build_principal +from .request_context import ( + RequestContext, + bind_request_context, + get_caller_mcp_client, + get_caller_principal, + require_caller_mcp_client, + require_caller_principal, +) +from .request_scoped_client import RequestScopedMcpClient +from .scopes import require_scopes, with_required_scopes + +__all__ = [ + "AbstractTokenStore", + "ApiClient", + "ApiClientOptions", + "ApiError", + "AuthServerUnavailableError", + "ConfigurationError", + "DownscopedTokenError", + "GetAccessTokenForConnectionError", + "GetClientCredentialsTokenError", + "GetTokenByExchangeProfileError", + "IndexedTokenStore", + "InsufficientScopeError", + "McpServerClient", + "MissingOrganizationError", + "MissingRequiredArgumentError", + "OrganizationNotAllowedError", + "Principal", + "RequestContext", + "RequestScopedMcpClient", + "TokenIndexMember", + "TokenSet", + "UnauthenticatedError", + "VerifiedToken", + "VerifyAccessTokenError", + "bind_request_context", + "build_principal", + "create_mcp_client", + "get_caller_mcp_client", + "get_caller_principal", + "require_caller_mcp_client", + "require_caller_principal", + "require_scopes", + "with_required_scopes", +] diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/client.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/client.py new file mode 100644 index 0000000..0a19c94 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/client.py @@ -0,0 +1,188 @@ +"""The server client: verify incoming tokens and exchange them for downstream tokens. + +This sits beside auth0-api-python. It owns no MCP framework and registers no tools. +It verifies the bearer token, delegates the OBO exchange to the core SDK (which handles +caching when a token_store is wired in via create_mcp_client), and hands tool code a +request-scoped client. Each framework adapter wires this into a request pipeline. +""" + +from __future__ import annotations + +from typing import Literal + +from auth0_api_python import ( + AbstractTokenStore, + ApiClient, + ApiClientOptions, + GetAccessTokenForConnectionError, + GetClientCredentialsTokenError, + TokenSet, + VerifiedToken, +) +from auth0_api_python.errors import ( + ConfigurationError, + GetTokenByExchangeProfileError, +) + +from .principal import Principal +from .request_scoped_client import RequestScopedMcpClient +from .utils._internal import exchange, telemetry + + +class McpServerClient: + """Verifies incoming tokens and exchanges them for downstream API tokens.""" + + def __init__( + self, + api_client: ApiClient, + ): + self._api = api_client + + @property + def domain(self) -> str | None: + """The tenant domain the core client was configured with, or None in multi-domain mode.""" + return self._api.options.domain + + @property + def audience(self) -> str: + """The API identifier the core client expects in incoming tokens.""" + return self._api.options.audience + + def with_token(self, verified: VerifiedToken, principal: Principal) -> RequestScopedMcpClient: + """Bind this client to one request's verified token and principal.""" + return RequestScopedMcpClient(self, verified, principal) + + async def verify_access_token(self, access_token: str) -> VerifiedToken: + """Verify the incoming token, returning it paired with its verified claims.""" + claims = await self._api.verify_access_token(access_token=access_token) + return VerifiedToken(access_token=access_token, claims=claims) + + async def get_token_on_behalf_of( + self, + verified: VerifiedToken, + *, + audience: str, + scope: str | None = None, + ) -> TokenSet: + """Exchange the caller's token for a downstream token for the given audience and scope. + + Raises: + DownscopedTokenError: If the exchange grants fewer scopes than were requested. + GetTokenByExchangeProfileError: If the exchanged token is already expired. + """ + result = await self._api.get_token_on_behalf_of( + verified.access_token, audience, scope, verified=verified + ) + exchange.reject_if_downscoped(scope, result) + exchange.reject_if_expired(result, GetTokenByExchangeProfileError) + return exchange.token_set(result) + + async def get_client_credentials_token( + self, *, audience: str, scope: str | None = None + ) -> TokenSet: + """Obtain a client credentials token for a server-to-server call. + + Args: + audience: Target API identifier. + scope: Optional space-separated scopes to request. + + Raises: + GetClientCredentialsTokenError: If client credentials are not configured, the token endpoint is unreachable, or the returned token is already expired. + ApiError: If the token endpoint returns an error. + DownscopedTokenError: If the token endpoint grants fewer scopes than were requested. + """ + result = await self._api.get_client_credentials_token(audience=audience, scope=scope) + exchange.reject_if_downscoped(scope, result) + exchange.reject_if_expired(result, GetClientCredentialsTokenError) + return exchange.token_set(result) + + async def get_token_for_connection( + self, + verified: VerifiedToken, + *, + connection: str, + login_hint: str | None = None, + ) -> TokenSet: + """Exchange the caller's token for a federated provider token via Token Vault. + + Args: + verified: The verified incoming token for the requesting user. + connection: The Auth0 federated connection identifier (e.g. "google-oauth2"). + login_hint: Optional email to pre-fill the federated login prompt. + + Raises: + GetAccessTokenForConnectionError: If client credentials are not configured, the exchange fails, or the returned token is already expired. + ApiError: If the token exchange endpoint returns an error. + """ + options: dict = { + "connection": connection, + "access_token": verified.access_token, + **({"login_hint": login_hint} if login_hint else {}), + } + result = await self._api.get_access_token_for_connection(options, verified=verified) + exchange.reject_if_expired(result, GetAccessTokenForConnectionError) + return exchange.token_set(result) + + +def create_mcp_client( + domain: str | None = None, + audience: str | None = None, + *, + client_id: str | None = None, + client_secret: str | None = None, + token_store: AbstractTokenStore | None = None, + scope_matching: Literal["strict", "non_strict"] = "strict", + organization_policy: Literal["required", "allow"] = "allow", + organization_id: str | list[str] | None = None, + client_info: dict | None = None, + api_client: ApiClient | None = None, +) -> McpServerClient: + """Build an McpServerClient. + + Args: + domain: The Auth0 tenant domain, for example "tenant.auth0.com". Required unless `api_client` is passed. + audience: The API identifier the incoming tokens are issued for. Required unless `api_client` is passed. + client_id: Needed for the on-behalf-of exchange, client credentials tokens and Token Vault. Token verification works without it. + client_secret: Needed alongside `client_id` for the same flows. + token_store: Optional store for the core SDK's token cache. + scope_matching: Cache scope-matching mode, "strict" or "non_strict". "non_strict" needs an `IndexedTokenStore`. + organization_policy: "required" rejects tokens without an `org_id` claim, "allow" accepts them. + organization_id: The Organization ID or IDs a token must carry. Only valid with `organization_policy="required"`. + client_info: Identifies a package built on this one in the Auth0-Client header. A dict with non-empty string "name" and "version" keys and an optional "env" dict of string keys and values. + api_client: A prebuilt core client to use as is. Pass it instead of the options above, including `domain` and `audience`. + + Returns: + The server client. + + Raises: + ConfigurationError: If `domain` or `audience` is missing without `api_client`, or if `api_client` is passed together with any of `domain`, `audience`, `client_id`, `client_secret`, `token_store`, `scope_matching`, `organization_policy`, `organization_id` or `client_info`, or if `client_info` does not have the shape described above. + """ + if api_client is None: + if domain is None or audience is None: + raise ConfigurationError("domain and audience are required unless api_client is passed") + api_client = ApiClient( + ApiClientOptions( + domain=domain, + audience=audience, + client_id=client_id, + client_secret=client_secret, + token_store=token_store, + scope_matching=scope_matching, + organization_policy=organization_policy, + organization_id=organization_id, + client_info=telemetry.build_client_info(client_info), + ) + ) + elif ( + domain is not None + or audience is not None + or client_id is not None + or client_secret is not None + or token_store is not None + or scope_matching != "strict" + or organization_policy != "allow" + or organization_id is not None + or client_info is not None + ): + raise ConfigurationError("api_client cannot be combined with other client options, because they would be ignored") + return McpServerClient(api_client) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/errors.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/errors.py new file mode 100644 index 0000000..5c87085 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/errors.py @@ -0,0 +1,40 @@ +"""Errors owned by auth0-ai-mcp. + +The core SDK (auth0-api-python) owns errors for the functions it provides, and this +package raises those directly. These are the errors with no core equivalent. They all +extend BaseAuthError so a caller can catch one base type across both layers. +""" + +from __future__ import annotations + +from auth0_api_python.errors import BaseAuthError + + +class UnauthenticatedError(BaseAuthError): + """No verified caller in the current request context. Raised inside the tool, so the framework reports it as a tool error.""" + + def get_status_code(self) -> int: + return 401 + + def get_error_code(self) -> str: + return "invalid_token" + + +class InsufficientScopeError(BaseAuthError): + """The caller's token is missing a required scope. Raised inside the tool, so the framework reports it as a tool error.""" + + def get_status_code(self) -> int: + return 403 + + def get_error_code(self) -> str: + return "insufficient_scope" + + +class DownscopedTokenError(BaseAuthError): + """The exchange granted fewer scopes than were requested. Error code `downscoped_token`, status 403.""" + + def get_status_code(self) -> int: + return 403 + + def get_error_code(self) -> str: + return "downscoped_token" diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py new file mode 100644 index 0000000..341ddc1 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py @@ -0,0 +1,219 @@ +"""ASGI middleware: wires Auth0 verification into any Starlette/FastAPI or official MCP app. + +The middleware verifies the incoming bearer token, builds the caller's Principal, and sets +the request context before the MCP handler runs, so tool code reaches the caller through +`get_caller_principal()` / `require_caller_mcp_client()`. A missing or invalid token +gets a 401 with a `WWW-Authenticate` challenge and never reaches a tool. If Auth0 cannot be reached +to verify the token, the response is a 503 without a challenge. It also serves the RFC 9728 +`/.well-known/oauth-protected-resource` metadata document, at the root and at the path derived +from the resource URL. WebSocket connections are refused. +""" + +from __future__ import annotations + +import json +import logging +from urllib.parse import urlsplit + +from auth0_api_python.errors import ( + AuthServerUnavailableError, + VerifyAccessTokenError, +) +from starlette.authentication import AuthCredentials +from starlette.types import ASGIApp, Receive, Scope, Send + +from .client import McpServerClient +from .principal import build_principal +from .request_context import RequestContext, bind_request_context +from .utils._internal import headers, mcp_session, urls + +logger = logging.getLogger(__name__) + +_PRM_PATH = "/.well-known/oauth-protected-resource" +_INVALID_DESCRIPTION = "The access token is not valid" + + +def build_protected_resource_metadata( + mcp_client: McpServerClient, + *, + resource_url: str | None = None, + authorization_servers: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: str | None = None, +) -> dict: + """Build the RFC 9728 protected-resource metadata document. + + Args: + mcp_client: The server client. Its audience is the default `resource_url` and its domain the default authorization server. + resource_url: Absolute http or https URL of the protected resource. Defaults to the client audience and must equal it when given. + authorization_servers: Issuer URLs to advertise, each an absolute https URL. Defaults to the client's tenant domain and is required when the client has no single domain. + scopes_supported: Optional scopes to advertise. Omitted from the document when None. + resource_name: Optional human-readable name for the resource. Omitted from the document when None. + resource_documentation: Optional URL of documentation for the resource. Omitted from the document when None. + + Returns: + The metadata document as a dict. + + Raises: + ConfigurationError: If `resource_url` differs from the client audience or is not an absolute http or https URL, or if `authorization_servers` cannot be resolved or holds anything but absolute https URLs. + """ + doc: dict = { + "resource": urls.resolve_resource_url(mcp_client, resource_url), + "authorization_servers": urls.resolve_authorization_servers(mcp_client, authorization_servers), + "bearer_methods_supported": ["header"], + } + if scopes_supported is not None: + doc["scopes_supported"] = scopes_supported + if resource_name is not None: + doc["resource_name"] = resource_name + if resource_documentation is not None: + doc["resource_documentation"] = resource_documentation + return doc + + +def build_www_authenticate( + resource_metadata_url: str, error: str | None = None, error_description: str | None = None +) -> str: + """Build a Bearer `WWW-Authenticate` challenge with every value made safe for a quoted header. + + Args: + resource_metadata_url: URL of the protected-resource metadata document. + error: Optional RFC 6750 error code. Leave None when the request carried no token. + error_description: Optional human-readable description, only sent alongside `error`. + + Returns: + The challenge, ASCII only, for example `Bearer resource_metadata="https://host/.well-known/oauth-protected-resource"`. + """ + params = [] + if error is not None: + params.append(f'error="{headers.header_safe(error)}"') + if error_description is not None: + params.append(f'error_description="{headers.header_safe(error_description)}"') + params.append(f'resource_metadata="{headers.header_safe(resource_metadata_url)}"') + return "Bearer " + ", ".join(params) + + +class Auth0AsgiMiddleware: + """Verifies the bearer token and populates the request context for each request. + + Args: + app: The ASGI app to protect. + mcp_client: The server client that verifies tokens. + resource_url: Absolute http or https URL of the protected resource. Defaults to the client audience and must equal it when given. + authorization_servers: Issuer URLs to advertise, each an absolute https URL. Defaults to the client's tenant domain and is required when the client has no single domain. + scopes_supported: Optional scopes to advertise in the metadata document. + resource_name: Optional human-readable name for the resource, advertised in the metadata document. + resource_documentation: Optional URL of documentation for the resource, advertised in the metadata document. + + Raises: + ConfigurationError: If `resource_url` differs from the client audience or is not an absolute http or https URL, or if `authorization_servers` cannot be resolved or holds anything but absolute https URLs. + """ + + def __init__( + self, + app: ASGIApp, + *, + mcp_client: McpServerClient, + resource_url: str | None = None, + authorization_servers: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: str | None = None, + ): + self._app = app + self._client = mcp_client + self._metadata = build_protected_resource_metadata( + mcp_client, + resource_url=resource_url, + authorization_servers=authorization_servers, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, + ) + parts = urlsplit(self._metadata["resource"]) + suffixed_path = _PRM_PATH + urls.resource_path(parts.path) + self._metadata_paths = {_PRM_PATH, suffixed_path} + self._metadata_url = f"{parts.scheme}://{parts.netloc}{suffixed_path}" + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "websocket": + await send({"type": "websocket.close", "code": 1008}) + return + if scope["type"] != "http": + await self._app(scope, receive, send) + return + + method = scope.get("method") + if method == "GET" and scope["path"] in self._metadata_paths: + await self._send_json(send, 200, self._metadata) + return + + # Browsers send a CORS preflight without credentials, so it cannot be verified. + if method == "OPTIONS": + await self._app(scope, receive, send) + return + + token = headers.bearer_token(scope) + if token is None: + await self._send_unauthorized(send) + return + + try: + verified = await self._client.verify_access_token(token) + principal = build_principal(verified.claims) + except AuthServerUnavailableError as exc: + logger.error("Auth0 could not be reached to verify the token: %s", exc) + await self._send_unavailable(send) + return + except VerifyAccessTokenError as exc: + logger.warning("Token rejected: %s", exc) + await self._send_unauthorized(send, error="invalid_token") + return + + context = RequestContext( + principal=principal, + mcp_client=self._client.with_token(verified, principal), + ) + if mcp_session.AuthenticatedUser is not None: + scope["user"] = mcp_session.AuthenticatedUser(mcp_session.access_token_info(principal, verified.claims)) + scope["auth"] = AuthCredentials(principal.scopes) + with bind_request_context(context): + await self._app(scope, receive, send) + + async def _send_json( + self, send: Send, status: int, body: dict, headers: list[tuple[bytes, bytes]] | None = None + ) -> None: + raw = json.dumps(body).encode() + response_headers = [(b"content-type", b"application/json")] + if headers: + response_headers.extend(headers) + await send( + {"type": "http.response.start", "status": status, "headers": response_headers} + ) + await send({"type": "http.response.body", "body": raw}) + + async def _send_unauthorized(self, send: Send, error: str | None = None) -> None: + if error is None: + challenge = build_www_authenticate(self._metadata_url) + message = "Unauthorized" + else: + challenge = build_www_authenticate(self._metadata_url, error, _INVALID_DESCRIPTION) + message = f"Unauthorized: {_INVALID_DESCRIPTION}" + payload = { + "jsonrpc": "2.0", + "error": {"code": -32001, "message": message}, + "id": None, + } + await self._send_json( + send, 401, payload, headers=[(b"www-authenticate", challenge.encode("ascii"))] + ) + + async def _send_unavailable(self, send: Send) -> None: + # No challenge, so the client keeps its token and retries instead of logging in again. + payload = { + "jsonrpc": "2.0", + "error": {"code": -32002, "message": "Service unavailable: the token could not be verified"}, + "id": None, + } + await self._send_json(send, 503, payload, headers=[(b"retry-after", b"5")]) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/principal.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/principal.py new file mode 100644 index 0000000..6e8bf48 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/principal.py @@ -0,0 +1,89 @@ +""" +Verified caller identity, normalized from access token claims. +""" + +import math +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from auth0_api_python.errors import VerifyAccessTokenError + + +@dataclass +class Principal: + """ + Normalized caller identity built from a verified access token's claims. + + Attributes: + sub: The subject claim identifying the caller. + expires_at: Unix epoch seconds the token expires, from the "exp" claim. + scopes: Parsed from the space-separated "scope" claim. Empty list if absent. + permissions: From the "permissions" claim. None when the claim is absent from + the token (RBAC not enabled on the API), a list (possibly empty) when present. + client_id: From the "azp" claim, falling back to "client_id". Always set, because a token with neither is rejected. + org_id: From the "org_id" claim. None when the token carries no Organization. + """ + + sub: str + expires_at: int | float + scopes: list[str] + permissions: list[str] | None + client_id: str + org_id: str | None + + +def build_principal(claims: Mapping[str, Any]) -> Principal: + """ + Build a Principal from a verified access token's claims. + + Args: + claims: The claims dict returned by ApiClient.verify_access_token. This must be + the already-verified claims, never a raw token or unverified input. + + Returns: + A Principal normalizing the claims tool code needs. + + Raises: + VerifyAccessTokenError: If "sub" is missing or blank, "exp" is missing or not a finite number, neither "azp" nor "client_id" is a non-blank string, "permissions" is not a list of strings, or "org_id" is not a string. + """ + sub = claims.get("sub") + if not isinstance(sub, str) or not sub.strip(): + raise VerifyAccessTokenError("Access token is missing the required 'sub' claim") + + exp = claims.get("exp") + if isinstance(exp, bool) or not isinstance(exp, (int, float)) or not math.isfinite(exp): + raise VerifyAccessTokenError("Access token has a missing or invalid 'exp' claim") + + client_id = next( + ( + value + for value in (claims.get("azp"), claims.get("client_id")) + if isinstance(value, str) and value.strip() + ), + None, + ) + if client_id is None: + raise VerifyAccessTokenError("Access token is missing the 'azp' or 'client_id' claim") + + permissions = claims.get("permissions") + if permissions is not None and ( + not isinstance(permissions, list) or not all(isinstance(p, str) for p in permissions) + ): + raise VerifyAccessTokenError("Access token has an invalid 'permissions' claim") + + org_id = claims.get("org_id") + if org_id is not None and not isinstance(org_id, str): + raise VerifyAccessTokenError("Access token has an invalid 'org_id' claim") + + scope = claims.get("scope") or "" + scopes = scope.split() if isinstance(scope, str) else [] + + return Principal( + sub=sub, + expires_at=exp, + scopes=scopes, + permissions=permissions, + client_id=client_id, + org_id=org_id, + ) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/py.typed b/packages/auth0-ai-mcp/src/auth0_ai_mcp/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/request_context.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/request_context.py new file mode 100644 index 0000000..8576db3 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/request_context.py @@ -0,0 +1,94 @@ +"""Per-request state, reachable from tool code without threading arguments. + +The adapter verifies the token and sets this contextvar before the MCP handler runs. +Tool code reads the caller and its request-scoped client from here, so a tool signature +stays clean and never has to accept an auth object it did not ask for. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar, Token +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from .errors import UnauthenticatedError +from .principal import Principal + +if TYPE_CHECKING: + from .request_scoped_client import RequestScopedMcpClient + + +@dataclass +class RequestContext: + """ + The per-request state that tool code reads. + + Attributes: + principal: The verified caller for this request. + mcp_client: The request-scoped client bound to that caller's token. + """ + + principal: Principal + mcp_client: RequestScopedMcpClient + + +_request_context: ContextVar[RequestContext | None] = ContextVar( + "auth0_ai_mcp_request_context", default=None +) + + +def set_request_context(context: RequestContext) -> Token[RequestContext | None]: + """Set the current request context, returning the token to reset it after.""" + return _request_context.set(context) + + +def reset_request_context(token: Token[RequestContext | None]) -> None: + """Reset the request context to its prior state.""" + _request_context.reset(token) + + +@contextmanager +def bind_request_context(context: RequestContext) -> Iterator[None]: + """Set the request context for the duration of a `with` block. + + Args: + context: The verified caller and request-scoped client for this request. + + Yields: + None. The context is reset on exit, including when the block raises. + """ + token = set_request_context(context) + try: + yield + finally: + reset_request_context(token) + + +def get_caller_principal() -> Principal | None: + """Return the verified caller for this request, or None outside a request.""" + ctx = _request_context.get() + return ctx.principal if ctx else None + + +def require_caller_principal() -> Principal: + """Return the verified caller, raising if called outside a request.""" + principal = get_caller_principal() + if principal is None: + raise UnauthenticatedError("No principal in the current request context.") + return principal + + +def get_caller_mcp_client() -> RequestScopedMcpClient | None: + """Return the request-scoped client, or None outside a request.""" + ctx = _request_context.get() + return ctx.mcp_client if ctx else None + + +def require_caller_mcp_client() -> RequestScopedMcpClient: + """Return the request-scoped client, raising if called outside a request.""" + client = get_caller_mcp_client() + if client is None: + raise UnauthenticatedError("No MCP client in the current request context.") + return client diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/request_scoped_client.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/request_scoped_client.py new file mode 100644 index 0000000..14f6b33 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/request_scoped_client.py @@ -0,0 +1,50 @@ +"""A client bound to one request's token. + +Tool code calls `get_token_on_behalf_of` on this without ever handling the raw +incoming token. The token is closed over here, so a tool cannot accidentally forward +or leak it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .principal import Principal + +if TYPE_CHECKING: + from auth0_api_python import TokenSet, VerifiedToken + + from .client import McpServerClient + + +class RequestScopedMcpClient: + """Wraps the shared server client with this request's verified token and principal.""" + + def __init__(self, client: McpServerClient, verified: VerifiedToken, principal: Principal): + self._client = client + self._verified = verified + self._principal = principal + + async def get_token_on_behalf_of(self, *, audience: str, scope: str | None = None) -> TokenSet: + """Exchange the caller's token for a downstream token for `audience`.""" + return await self._client.get_token_on_behalf_of( + self._verified, audience=audience, scope=scope + ) + + async def get_client_credentials_token( + self, *, audience: str, scope: str | None = None + ) -> TokenSet: + """Obtain a client credentials token for a server-to-server call.""" + return await self._client.get_client_credentials_token(audience=audience, scope=scope) + + async def get_token_for_connection( + self, *, connection: str, login_hint: str | None = None + ) -> TokenSet: + """Exchange the caller's token for a federated provider token via Token Vault.""" + return await self._client.get_token_for_connection( + self._verified, connection=connection, login_hint=login_hint + ) + + def get_principal(self) -> Principal: + """Return the verified caller for this request.""" + return self._principal diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/scopes.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/scopes.py new file mode 100644 index 0000000..330ff93 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/scopes.py @@ -0,0 +1,69 @@ +"""Scope-gating decorator for async MCP tool handlers. + +`with_required_scopes` checks the incoming MCP token's scopes before the tool body +runs and raises if the caller is missing any of the requested scopes. It reads the +verified caller from the request context the adapter set up, so the tool body does +not receive an auth argument. +""" + +from __future__ import annotations + +import functools +import logging +from collections.abc import Awaitable, Callable +from typing import TypeVar + +from .errors import InsufficientScopeError, UnauthenticatedError +from .principal import Principal +from .request_context import get_caller_principal +from .utils._internal import scope_input + +logger = logging.getLogger(__name__) + +F = TypeVar("F", bound=Callable[..., Awaitable[object]]) + + +def require_scopes(principal: Principal | None, scopes: str | list[str]) -> None: + """Raise if the principal is missing any of the given scopes. + + Args: + principal: The verified caller, or None when there is no request context. + scopes: A single required scope, or a list of scopes that must all be present. + + Raises: + ConfigurationError: If `scopes` is empty, blank, or holds a non-string or blank entry. + UnauthenticatedError: If `principal` is None. + InsufficientScopeError: If the principal is missing any required scope. + """ + required = scope_input.normalize_scopes(scopes) + if principal is None: + raise UnauthenticatedError("No principal in the current request context.") + logger.debug("Token scopes: %s, required: %s", principal.scopes, required) + if any(s not in principal.scopes for s in required): + logger.info("Scope check failed. The token is missing one or more required scopes.") + raise InsufficientScopeError("Missing one or more required scopes.") + + +def with_required_scopes(scopes: str | list[str]) -> Callable[[F], F]: + """Return a decorator that gates an async handler on the given scopes. + + Args: + scopes: A single required scope, or a list of scopes that must all be present. + + Returns: + A decorator that runs the scope check before the handler body. + + Raises: + ConfigurationError: At decoration time, if `scopes` is empty, blank, or holds a non-string or blank entry. + """ + required = scope_input.normalize_scopes(scopes) + + def decorator(func: F) -> F: + @functools.wraps(func) + async def wrapper(*args: object, **kwargs: object) -> object: + require_scopes(get_caller_principal(), required) + return await func(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + return decorator diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/__init__.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/__init__.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/__init__.py new file mode 100644 index 0000000..fcae5f0 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/__init__.py @@ -0,0 +1 @@ +"""Private helpers. Not part of the public API.""" diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/exchange.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/exchange.py new file mode 100644 index 0000000..8e206d7 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/exchange.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import time + +from auth0_api_python import TokenSet +from auth0_api_python.errors import BaseAuthError + +from ...errors import DownscopedTokenError + + +def token_set(result: dict) -> TokenSet: + """Build the same `TokenSet` shape for every flow, adding `granted_scopes` when the response has a scope.""" + tokens: TokenSet = { + "access_token": result["access_token"], + "expires_at": result["expires_at"], + } + if result.get("scope"): + tokens["granted_scopes"] = result["scope"] + return tokens + + +def reject_if_expired(result: dict, error: type[BaseAuthError]) -> None: + """Raise `error` if the returned token has no usable `expires_at` or has already expired.""" + expires_at = result.get("expires_at") + if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)) or expires_at <= time.time(): + raise error("The exchanged token is already expired.") + + +def reject_if_downscoped(requested: str | None, result: dict) -> None: + """Raise if the exchange granted fewer scopes than were requested. + + An absent `scope` response field means the grant matched the request (RFC 6749 + section 5.1), so only a present field is diffed. The message stays generic on + purpose and does not name the missing scopes. + """ + if not requested or "scope" not in result: + return + granted = {s for s in (result["scope"] or "").split() if s} + missing = {s for s in requested.split() if s} - granted + if missing: + raise DownscopedTokenError( + "Exchanged token did not include all requested scopes." + ) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/headers.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/headers.py new file mode 100644 index 0000000..f872ff6 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/headers.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from starlette.types import Scope + + +def header_safe(value: str) -> str: + """Replace anything that could end or escape a quoted header value, or is not printable ASCII.""" + quoted = value.replace('"', "'") + return "".join(ch if " " <= ch <= "~" and ch != "\\" else " " for ch in quoted) + + +def bearer_token(scope: Scope) -> str | None: + """Pull the bearer token from the Authorization header, or None if absent or blank.""" + for name, value in scope.get("headers", []): + if name == b"authorization": + parts = value.decode("latin-1").split(" ", 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1].strip() or None + return None diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/mcp_session.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/mcp_session.py new file mode 100644 index 0000000..81d9ae9 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/mcp_session.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ...principal import Principal + +try: + from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser + from mcp.server.auth.provider import AccessToken +except ImportError: # Without mcp there is no session manager to bind, so scope["user"] is skipped. + AuthenticatedUser = None + AccessToken = None + +# Tool code can reach scope["user"], so the raw bearer token never goes there. +_REDACTED_VALUE = "redacted" + + +def access_token_info(principal: Principal, claims: Mapping[str, Any]) -> AccessToken: + """Describe the caller to the mcp session manager, which keys a session on client, issuer and subject.""" + issuer = claims.get("iss") + return AccessToken( + token=_REDACTED_VALUE, + client_id=principal.client_id, + scopes=principal.scopes, + expires_at=int(principal.expires_at), + subject=principal.sub, + claims={"iss": issuer} if isinstance(issuer, str) else None, + ) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py new file mode 100644 index 0000000..a4a6247 --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from auth0_api_python.errors import ConfigurationError + + +def normalize_scopes(scopes: str | list[str]) -> list[str]: + """Return the required scopes as a list, rejecting an empty or malformed argument. + + Args: + scopes: A single scope string or a list of scope strings. + + Returns: + The required scopes as a list. + + Raises: + ConfigurationError: If `scopes` is empty, blank, not a string or list, or holds a non-string or blank entry. + """ + required = [scopes] if isinstance(scopes, str) else scopes + if not isinstance(required, list) or not required: + raise ConfigurationError("scopes must be a non-empty string or a non-empty list of strings") + if any(not isinstance(s, str) or not s.strip() for s in required): + raise ConfigurationError("scopes must not contain blank or non-string entries") + return list(required) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/telemetry.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/telemetry.py new file mode 100644 index 0000000..2d7128a --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/telemetry.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import importlib.metadata + +_PACKAGE_NAME = "auth0-ai-mcp" + + +def _package_version() -> str: + try: + return importlib.metadata.version(_PACKAGE_NAME) + except importlib.metadata.PackageNotFoundError: + return "unknown" + + +def build_client_info(wrapper: dict | None = None) -> dict: + """Build the client_info passed to the core SDK, reporting this package or the wrapper built on it.""" + if wrapper is None: + return {"name": _PACKAGE_NAME, "version": _package_version()} + if not isinstance(wrapper, dict) or not isinstance(wrapper.get("env", {}), dict): + return wrapper + return {**wrapper, "env": {**wrapper.get("env", {}), _PACKAGE_NAME: _package_version()}} diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/urls.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/urls.py new file mode 100644 index 0000000..ea1eb5e --- /dev/null +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/urls.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urlsplit + +from auth0_api_python.errors import ConfigurationError +from auth0_api_python.utils import normalize_domain + +if TYPE_CHECKING: + from ...client import McpServerClient + + +def resolve_resource_url(mcp_client: McpServerClient, resource_url: str | None) -> str: + """Return the advertised resource URL, defaulting to the client audience, which tokens must carry as `aud`.""" + audience = mcp_client.audience + if resource_url is None: + resource_url = audience + elif resource_url != audience: + raise ConfigurationError( + "resource_url must equal the client audience, because tokens for any other audience are rejected. " + "Omit resource_url to use the audience." + ) + if not is_plain_url(resource_url, ("http", "https")): + raise ConfigurationError( + "resource_url (the client audience unless set) must be an absolute http or https URL with a host and no fragment, credentials or whitespace" + ) + return resource_url + + +def resolve_authorization_servers( + mcp_client: McpServerClient, authorization_servers: list[str] | None +) -> list[str]: + """Return the advertised issuers, defaulting to the client's tenant domain.""" + if authorization_servers is None: + domain = mcp_client.domain + if not domain: + raise ConfigurationError( + "authorization_servers is required when the client has no single domain" + ) + try: + issuer = normalize_domain(domain) + except ValueError as exc: + raise ConfigurationError(f"domain is not a valid tenant domain: {exc}") from exc + # The core builds its discovery URL from the raw domain, so anything normalize_domain trims would break verification. + if issuer != f"https://{domain.lower()}/" or any(ch.isspace() for ch in issuer): + raise ConfigurationError("domain must be a bare host such as tenant.auth0.com, with no scheme, path or whitespace") + return [issuer] + if not isinstance(authorization_servers, list) or not authorization_servers: + raise ConfigurationError("authorization_servers must be a non-empty list of https URLs") + for server in authorization_servers: + if not is_plain_url(server, ("https",)) or urlsplit(server).query: + raise ConfigurationError( + "authorization_servers must hold absolute https URLs with a host and no query, fragment, credentials or whitespace" + ) + return authorization_servers + + +def is_plain_url(value: object, schemes: tuple[str, ...]) -> bool: + """True for an absolute URL of one of `schemes` with a host and no fragment, credentials or whitespace.""" + if not isinstance(value, str) or any(ch.isspace() or not ch.isprintable() for ch in value): + return False + parts = urlsplit(value) + return parts.scheme in schemes and bool(parts.hostname) and not parts.fragment and parts.username is None and parts.password is None + + +def resource_path(path: str) -> str: + """The resource URL's path for RFC 9728 path insertion, empty when the path is only "/".""" + return "" if path == "/" else path diff --git a/packages/auth0-ai-mcp/tests/test_client.py b/packages/auth0-ai-mcp/tests/test_client.py new file mode 100644 index 0000000..862d9a5 --- /dev/null +++ b/packages/auth0-ai-mcp/tests/test_client.py @@ -0,0 +1,545 @@ +"""OBO exchange and downscope-detection behavior.""" + +import base64 +import importlib.metadata +import json +import time + +import pytest +from auth0_api_python import ApiClient, ApiClientOptions, VerifiedToken +from auth0_api_python.errors import ApiError, ConfigurationError, GetTokenByExchangeProfileError + +from auth0_ai_mcp import ( + AbstractTokenStore, + DownscopedTokenError, + GetAccessTokenForConnectionError, + GetClientCredentialsTokenError, + IndexedTokenStore, + McpServerClient, + Principal, + RequestScopedMcpClient, + create_mcp_client, +) + + +def _jwt(claims: dict) -> str: + """Build an unsigned JWT carrying the given claims.""" + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() + return f"header.{payload}.sig" + + +def _verified(sub: str = "user_1", org_id: str | None = None) -> VerifiedToken: + """A verified token and its claims for OBO calls.""" + claims = {"sub": sub, "iss": "https://tenant.example/"} + if org_id is not None: + claims["org_id"] = org_id + return VerifiedToken(access_token=_jwt(claims), claims=claims) + + +_KEEP = object() +_MISSING = object() + + +class StubApiClient: + """Stands in for auth0-api-python's ApiClient, recording exchange calls.""" + + def __init__( + self, + *, + granted_scope=None, + include_scope=True, + expires_at=None, + raises=None, + cc_scope_response=None, + expires_at_override=_KEEP, + ): + self.granted_scope = granted_scope + self.include_scope = include_scope + self.expires_at = expires_at if expires_at is not None else int(time.time()) + 3600 + self.expires_at_override = expires_at_override + self.raises = raises + self.exchange_calls = 0 + self.last_verified = None + self.last_access_token = None + self.last_scope = None + self.cc_calls = 0 + self.cc_last_audience = None + self.cc_last_scope = None + self.cc_scope_response = cc_scope_response + self.connection_calls = 0 + self.last_connection_options = None + + async def verify_access_token(self, access_token: str): + return {"sub": "user_1", "exp": int(time.time()) + 3600, "scope": "mcp:use"} + + async def get_token_on_behalf_of(self, access_token, audience, scope=None, *, verified=None): + self.exchange_calls += 1 + self.last_verified = verified + self.last_access_token = access_token + self.last_scope = scope + if self.raises is not None: + raise self.raises + granted = self.granted_scope if self.granted_scope is not None else (scope or "") + result = { + "access_token": f"downstream-for-{audience}", + "expires_in": self.expires_at - int(time.time()), + "expires_at": self.expires_at, + } + if self.include_scope: + result["scope"] = granted + return self._with_expiry(result) + + def _with_expiry(self, result): + if self.expires_at_override is _MISSING: + result.pop("expires_at") + elif self.expires_at_override is not _KEEP: + result["expires_at"] = self.expires_at_override + return result + + async def get_client_credentials_token(self, audience, scope=None): + self.cc_calls += 1 + self.cc_last_audience = audience + self.cc_last_scope = scope + if self.raises is not None: + raise self.raises + result = {"access_token": f"m2m-for-{audience}", "expires_in": 3600, "expires_at": self.expires_at} + if self.cc_scope_response is not None: + result["scope"] = self.cc_scope_response + return self._with_expiry(result) + + async def get_access_token_for_connection(self, options, *, verified=None): + self.connection_calls += 1 + self.last_connection_options = options + self.last_verified = verified + if self.raises is not None: + raise self.raises + connection = options.get("connection", "") + return self._with_expiry( + {"access_token": f"vault-for-{connection}", "expires_in": 3600, "expires_at": self.expires_at} + ) + + +async def test_exchange_returns_downstream_token(): + api = StubApiClient() + client = McpServerClient(api) + + result = await client.get_token_on_behalf_of( + _verified(), audience="https://orders/", scope="cancel:orders" + ) + + assert result == { + "access_token": "downstream-for-https://orders/", + "expires_at": api.expires_at, + "granted_scopes": "cancel:orders", + } + assert api.exchange_calls == 1 + + +_ERROR_BY_FLOW = { + "obo": GetTokenByExchangeProfileError, + "client_credentials": GetClientCredentialsTokenError, + "token_vault": GetAccessTokenForConnectionError, +} + + +async def _run_flow(client: McpServerClient, flow: str, scope: str | None = "cancel:orders"): + if flow == "obo": + return await client.get_token_on_behalf_of(_verified(), audience="https://orders/", scope=scope) + if flow == "client_credentials": + return await client.get_client_credentials_token(audience="https://orders/", scope=scope) + return await client.get_token_for_connection(_verified(), connection="google-oauth2") + + +@pytest.mark.parametrize("flow", ["obo", "client_credentials"]) +async def test_absent_scope_in_response_means_granted_matched_and_is_not_downscoped(flow): + """RFC 6749: an omitted scope field means the grant matched the request.""" + client = McpServerClient(StubApiClient(include_scope=False)) + + result = await _run_flow(client, flow, scope="read:orders write:orders") + + assert "granted_scopes" not in result + + +async def test_verify_access_token_returns_verified_token_carrying_the_original_access_token(): + client = McpServerClient(StubApiClient()) + + verified = await client.verify_access_token("the.raw.token") + + assert verified.access_token == "the.raw.token" + assert verified.claims["sub"] == "user_1" + + +async def test_exchange_forwards_verified_token_to_core(): + """The verified token is forwarded so core exchanges its access token and skips re-verifying.""" + api = StubApiClient() + client = McpServerClient(api) + verified = _verified() + + await client.get_token_on_behalf_of( + verified, audience="https://orders/", scope="cancel:orders" + ) + + assert api.last_verified is verified + assert api.last_verified.claims["sub"] == "user_1" + + +async def test_downscoped_exchange_raises_downscoped_token_error(): + """A shortfall between requested and granted scopes raises the MCP-owned error.""" + api = StubApiClient(granted_scope="read:orders") + client = McpServerClient(api) + + with pytest.raises(DownscopedTokenError): + await client.get_token_on_behalf_of( + _verified(), audience="https://orders/", scope="cancel:orders" + ) + + +def test_downscoped_error_has_distinct_error_code(): + error = DownscopedTokenError("x") + assert error.get_error_code() == "downscoped_token" + assert error.get_status_code() == 403 + + +async def test_downscoped_error_message_does_not_leak_scopes(): + """The message stays generic and never embeds the granted or missing scope values.""" + api = StubApiClient(granted_scope="read:orders") + client = McpServerClient(api) + + with pytest.raises(DownscopedTokenError) as exc_info: + await client.get_token_on_behalf_of( + _verified(), audience="https://orders/", scope="cancel:orders" + ) + + message = str(exc_info.value) + assert "cancel:orders" not in message + assert "read:orders" not in message + + +@pytest.mark.parametrize("flow", ["obo", "client_credentials"]) +async def test_empty_scope_in_response_is_downscoped(flow): + """A present but empty scope field means nothing was granted, unlike an absent one.""" + client = McpServerClient(StubApiClient(granted_scope="", cc_scope_response="")) + + with pytest.raises(DownscopedTokenError): + await _run_flow(client, flow) + + +async def test_no_requested_scope_skips_downscope_check(): + api = StubApiClient(granted_scope="read:orders") + client = McpServerClient(api) + + result = await client.get_token_on_behalf_of( + _verified(), audience="https://orders/", scope=None + ) + + assert result["access_token"] == "downstream-for-https://orders/" + + +async def test_exchange_rejection_propagates_core_error(): + """A core ApiError from the exchange propagates unchanged. The MCP layer does not wrap it.""" + api = StubApiClient(raises=ApiError("access_denied", "rejected by Auth0", 403)) + client = McpServerClient(api) + + with pytest.raises(ApiError): + await client.get_token_on_behalf_of( + _verified(), audience="https://orders/", scope="read:orders" + ) + + +@pytest.mark.parametrize("flow", ["obo", "client_credentials", "token_vault"]) +@pytest.mark.parametrize( + "expires_at", + [int(time.time()) - 1, _MISSING, None, "soon"], + ids=["past", "missing", "none", "non_numeric"], +) +async def test_result_without_a_future_numeric_expires_at_is_rejected(flow, expires_at): + client = McpServerClient(StubApiClient(expires_at_override=expires_at)) + + with pytest.raises(_ERROR_BY_FLOW[flow]): + await _run_flow(client, flow) + + +async def test_get_client_credentials_token_returns_token_set(): + api = StubApiClient() + client = McpServerClient(api) + + result = await client.get_client_credentials_token(audience="https://payments/", scope="process:payments") + + assert result["access_token"] == "m2m-for-https://payments/" + assert api.cc_calls == 1 + assert api.cc_last_audience == "https://payments/" + assert api.cc_last_scope == "process:payments" + + +async def test_get_client_credentials_token_raises_when_downscoped(): + api = StubApiClient(cc_scope_response="read:payments") + client = McpServerClient(api) + + with pytest.raises(DownscopedTokenError): + await client.get_client_credentials_token( + audience="https://payments/", scope="process:payments read:payments" + ) + + +async def test_get_client_credentials_token_passes_when_granted_is_a_reordered_superset(): + api = StubApiClient(cc_scope_response="read:payments process:payments extra:scope") + client = McpServerClient(api) + + result = await client.get_client_credentials_token( + audience="https://payments/", scope="process:payments read:payments" + ) + + assert result["granted_scopes"] == "read:payments process:payments extra:scope" + + +async def test_get_client_credentials_token_propagates_error(): + api = StubApiClient(raises=GetClientCredentialsTokenError("no credentials")) + client = McpServerClient(api) + + with pytest.raises(GetClientCredentialsTokenError): + await client.get_client_credentials_token(audience="https://payments/") + + +async def test_get_token_for_connection_returns_token_set(): + api = StubApiClient() + client = McpServerClient(api) + verified = _verified() + + result = await client.get_token_for_connection(verified, connection="google-oauth2") + + assert result["access_token"] == "vault-for-google-oauth2" + assert api.connection_calls == 1 + assert api.last_connection_options == {"connection": "google-oauth2", "access_token": verified.access_token} + assert api.last_verified is verified + + +async def test_get_token_for_connection_includes_login_hint_when_provided(): + api = StubApiClient() + client = McpServerClient(api) + verified = _verified() + + await client.get_token_for_connection(verified, connection="google-oauth2", login_hint="user@example.com") + + assert api.last_connection_options == { + "connection": "google-oauth2", + "access_token": verified.access_token, + "login_hint": "user@example.com", + } + + +async def test_get_token_for_connection_skips_downscope_check(): + """Token Vault scopes are set on the connection, so a granted scope field is not compared.""" + api = StubApiClient() + + async def connection_with_scope(options, *, verified=None): + return {"access_token": "vault", "expires_in": 3600, "expires_at": api.expires_at, "scope": "other"} + + api.get_access_token_for_connection = connection_with_scope + client = McpServerClient(api) + + result = await client.get_token_for_connection(_verified(), connection="google-oauth2") + + assert result["access_token"] == "vault" + assert result["granted_scopes"] == "other" + + +async def test_get_token_for_connection_propagates_error(): + api = StubApiClient(raises=GetAccessTokenForConnectionError("exchange failed")) + client = McpServerClient(api) + + with pytest.raises(GetAccessTokenForConnectionError): + await client.get_token_for_connection(_verified(), connection="google-oauth2") + + +def _principal() -> Principal: + return Principal(sub="user_1", expires_at=9999999999, scopes=[], permissions=None, client_id="client_1", org_id=None) + + +async def test_request_scoped_get_token_on_behalf_of_forwards_scope_and_verified_token(): + api = StubApiClient() + verified = _verified() + scoped = RequestScopedMcpClient(McpServerClient(api), verified, _principal()) + + await scoped.get_token_on_behalf_of(audience="https://orders/", scope="cancel:orders") + + assert api.last_scope == "cancel:orders" + assert api.last_access_token == verified.access_token + assert api.last_verified is verified + + +async def test_request_scoped_get_client_credentials_token(): + api = StubApiClient() + server_client = McpServerClient(api) + scoped = RequestScopedMcpClient(server_client, _verified(), _principal()) + + result = await scoped.get_client_credentials_token(audience="https://payments/", scope="process:payments") + + assert result["access_token"] == "m2m-for-https://payments/" + assert api.cc_last_audience == "https://payments/" + assert api.cc_last_scope == "process:payments" + + +async def test_request_scoped_get_token_for_connection_injects_verified(): + """The request-scoped client injects self._verified. The caller does not supply it.""" + api = StubApiClient() + verified = _verified() + server_client = McpServerClient(api) + scoped = RequestScopedMcpClient(server_client, verified, _principal()) + + result = await scoped.get_token_for_connection(connection="google-oauth2", login_hint="user@example.com") + + assert result["access_token"] == "vault-for-google-oauth2" + assert api.last_verified is verified + assert api.last_connection_options["login_hint"] == "user@example.com" + + +@pytest.mark.parametrize( + "option", + [ + {"domain": "tenant.example"}, + {"audience": "https://api/"}, + {"client_id": "id"}, + {"client_secret": "secret"}, + {"token_store": object()}, + {"scope_matching": "non_strict"}, + {"organization_policy": "required"}, + {"organization_id": "org_1"}, + {"client_info": {"name": "wrapper", "version": "1.0.0"}}, + ], +) +def test_create_mcp_client_rejects_options_passed_with_api_client(option): + with pytest.raises(ConfigurationError): + create_mcp_client(api_client=StubApiClient(), **option) + + +@pytest.mark.parametrize("args", [{}, {"domain": "tenant.example"}, {"audience": "https://api/"}]) +def test_create_mcp_client_needs_domain_and_audience_without_api_client(args): + with pytest.raises(ConfigurationError, match="domain and audience"): + create_mcp_client(**args) + + +def test_client_domain_is_none_in_multi_domain_mode(): + client = create_mcp_client( + api_client=ApiClient(ApiClientOptions(domains=["a.example", "b.example"], audience="https://api/")) + ) + + assert client.domain is None + assert client.audience == "https://api/" + + +def test_create_mcp_client_forwards_org_policy_to_core(): + client = create_mcp_client( + "tenant.auth0.com", + "https://api/", + organization_policy="required", + organization_id="org_abc", + ) + assert client._api.options.organization_policy == "required" + assert client._api.options.organization_id == "org_abc" + + +def test_create_mcp_client_forwards_client_credentials_to_core(): + client = create_mcp_client( + "tenant.auth0.com", "https://api/", client_id="cid", client_secret="csecret" + ) + assert client._api.options.client_id == "cid" + assert client._api.options.client_secret == "csecret" + + +def test_create_mcp_client_passes_token_store_to_api_client_options(): + """create_mcp_client wires token_store into ApiClientOptions so core's cache is active.""" + + class _MinimalStore(AbstractTokenStore): + async def get(self, key): + return None + + async def set(self, key, value): + pass + + async def delete(self, key): + pass + + store = _MinimalStore(secret="s" * 32) + client = create_mcp_client("tenant.auth0.com", "https://api/", token_store=store) + assert client._api.options.token_store is store + + +def test_create_mcp_client_forwards_scope_matching_to_core(): + class _MinimalIndexedStore(IndexedTokenStore): + async def get(self, key): + return None + + async def set(self, key, value): + pass + + async def delete(self, key): + pass + + async def add_index_member(self, index_key, member): + pass + + async def list_index_members(self, index_key): + return [] + + client = create_mcp_client( + "tenant.auth0.com", + "https://api/", + token_store=_MinimalIndexedStore(secret="s" * 32), + scope_matching="non_strict", + ) + assert client._api.options.scope_matching == "non_strict" + + +def test_create_mcp_client_reports_this_package_to_core(): + client = create_mcp_client("tenant.auth0.com", "https://api/") + + assert client._api.options.client_info == { + "name": "auth0-ai-mcp", + "version": importlib.metadata.version("auth0-ai-mcp"), + } + + +def test_create_mcp_client_reports_the_wrapper_and_lists_this_package_in_env(): + client = create_mcp_client( + "tenant.auth0.com", + "https://api/", + client_info={"name": "auth0-ai-fastmcp", "version": "1.2.3", "env": {"fastmcp": "3.0"}}, + ) + + assert client._api.options.client_info == { + "name": "auth0-ai-fastmcp", + "version": "1.2.3", + "env": {"fastmcp": "3.0", "auth0-ai-mcp": importlib.metadata.version("auth0-ai-mcp")}, + } + + +def test_create_mcp_client_wrapper_cannot_replace_this_packages_env_entry(): + client = create_mcp_client( + "tenant.auth0.com", + "https://api/", + client_info={"name": "wrapper", "version": "1.0.0", "env": {"auth0-ai-mcp": "0.0.0"}}, + ) + + assert client._api.options.client_info["env"]["auth0-ai-mcp"] == importlib.metadata.version("auth0-ai-mcp") + + +def test_client_info_version_is_unknown_when_the_package_is_not_installed(monkeypatch): + def missing(name): + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, "version", missing) + + assert create_mcp_client("tenant.auth0.com", "https://api/")._api.options.client_info["version"] == "unknown" + + +@pytest.mark.parametrize( + "client_info", + [ + "wrapper", + {"version": "1.0.0"}, + {"name": "wrapper", "version": "1.0.0", "env": "fastmcp"}, + ], +) +def test_create_mcp_client_rejects_malformed_client_info(client_info): + with pytest.raises(ConfigurationError, match="client_info"): + create_mcp_client("tenant.auth0.com", "https://api/", client_info=client_info) diff --git a/packages/auth0-ai-mcp/tests/test_middleware.py b/packages/auth0-ai-mcp/tests/test_middleware.py new file mode 100644 index 0000000..920d41b --- /dev/null +++ b/packages/auth0-ai-mcp/tests/test_middleware.py @@ -0,0 +1,687 @@ +"""ASGI middleware behavior: bearer handling, request context, and protected-resource metadata.""" + +import base64 +import json +import logging +from types import SimpleNamespace + +import httpx +import pytest +from auth0_api_python.errors import ( + AuthServerUnavailableError, + ConfigurationError, + VerifyAccessTokenError, +) +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.authentication import AuthCredentials + +from auth0_ai_mcp import ( + McpServerClient, + create_mcp_client, + get_caller_mcp_client, + get_caller_principal, +) +from auth0_ai_mcp.middleware import ( + Auth0AsgiMiddleware, + build_protected_resource_metadata, + build_www_authenticate, +) + +RESOURCE_URL = "https://mcp.example/mcp" +DOMAIN = "tenant.example" +PRM_URL = "https://mcp.example/.well-known/oauth-protected-resource/mcp" + + +class StubApiClient: + """Stands in for auth0-api-python's ApiClient, returning claims or raising on verify and recording exchanges.""" + + def __init__(self, *, claims=None, raises=None, domain=DOMAIN, audience=RESOURCE_URL, domains=None): + self.claims = claims + self.raises = raises + self.options = SimpleNamespace(domain=domain, audience=audience, domains=domains) + self.verified_tokens = [] + self.exchanged = [] + + async def verify_access_token(self, access_token): + self.verified_tokens.append(access_token) + if self.raises is not None: + raise self.raises + return self.claims + + async def get_token_on_behalf_of(self, access_token, audience, scope, *, verified): + self.exchanged.append((access_token, verified.claims)) + return {"access_token": "downstream", "expires_at": 4102444800} + + +class StubApp: + """Records whether it ran and which caller it saw.""" + + def __init__(self, *, raises=None): + self.raises = raises + self.calls = 0 + self.seen_principal = None + self.seen_client = None + self.seen_scope = None + + async def __call__(self, scope, receive, send): + self.calls += 1 + self.seen_principal = get_caller_principal() + self.seen_client = get_caller_mcp_client() + self.seen_scope = scope + if self.raises is not None: + raise self.raises + + +def _middleware(app, api, **kwargs): + return Auth0AsgiMiddleware( + app, + mcp_client=McpServerClient(api), + **kwargs, + ) + + +def _scope(path="/mcp", headers=None, scope_type="http", method="POST"): + return {"type": scope_type, "path": path, "method": method, "headers": headers or []} + + +def _bearer(token): + return [(b"authorization", f"Bearer {token}".encode())] + + +async def _call(middleware, scope): + sent = [] + + async def receive(): + return {"type": "http.request"} + + async def send(message): + sent.append(message) + + await middleware(scope, receive, send) + return sent + + +def _status(sent): + return sent[0]["status"] + + +def _header(sent, name): + return dict(sent[0]["headers"]).get(name) + + +def _body(sent): + return json.loads(sent[1]["body"]) + + +def _claims(**overrides): + claims = {"sub": "user_1", "exp": 4102444800, "scope": "read:x write:x", "azp": "client_1"} + claims.update(overrides) + return claims + + +async def test_missing_bearer_token_returns_401_and_skips_app(): + app = StubApp() + api = StubApiClient(claims=_claims()) + + sent = await _call(_middleware(app, api), _scope()) + + assert _status(sent) == 401 + assert _header(sent, b"www-authenticate") == f'Bearer resource_metadata="{PRM_URL}"'.encode() + assert _body(sent) == { + "jsonrpc": "2.0", + "error": {"code": -32001, "message": "Unauthorized"}, + "id": None, + } + assert app.calls == 0 + assert api.verified_tokens == [] + + +async def test_non_bearer_authorization_header_is_treated_as_missing(): + app = StubApp() + + sent = await _call( + _middleware(app, StubApiClient(claims=_claims())), + _scope(headers=[(b"authorization", b"Basic abc123")]), + ) + + assert _status(sent) == 401 + assert app.calls == 0 + + +@pytest.mark.parametrize("value", [b"Bearer ", b"Bearer ", b"bearer \t"]) +async def test_blank_bearer_token_is_treated_as_missing(value): + app = StubApp() + api = StubApiClient(claims=_claims()) + + sent = await _call(_middleware(app, api), _scope(headers=[(b"authorization", value)])) + + assert _status(sent) == 401 + assert _header(sent, b"www-authenticate") == f'Bearer resource_metadata="{PRM_URL}"'.encode() + assert app.calls == 0 + assert api.verified_tokens == [] + + +async def test_bearer_scheme_is_matched_case_insensitively(): + app = StubApp() + api = StubApiClient(claims=_claims()) + + await _call(_middleware(app, api), _scope(headers=[(b"authorization", b"bearer good")])) + + assert app.calls == 1 + assert api.verified_tokens == ["good"] + + +async def test_challenge_uses_root_metadata_url_when_resource_has_no_path(): + middleware = Auth0AsgiMiddleware( + StubApp(), + mcp_client=McpServerClient(StubApiClient(claims=_claims(), audience="https://mcp.example/")), + ) + + sent = await _call(middleware, _scope()) + + assert _header(sent, b"www-authenticate") == ( + b'Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource"' + ) + + +def _unsigned_jwt(header, payload): + def encode(value): + return base64.urlsafe_b64encode(json.dumps(value).encode()).rstrip(b"=").decode() + + return f"{encode(header)}.{encode(payload)}.signature" + + +@pytest.mark.parametrize( + "header, payload, reason", + [ + ([1], {}, "segment is not a JSON object"), + ({"alg": 5}, {}, "'alg' must be a string"), + ({"alg": "RS256", "kid": "k"}, [1], "segment is not a JSON object"), + ], +) +async def test_a_malformed_token_returns_401_for_a_real_client(header, payload, reason, caplog): + app = StubApp() + middleware = Auth0AsgiMiddleware(app, mcp_client=create_mcp_client(DOMAIN, RESOURCE_URL)) + + with caplog.at_level(logging.WARNING, logger="auth0_ai_mcp.middleware"): + sent = await _call(middleware, _scope(headers=_bearer(_unsigned_jwt(header, payload)))) + + assert _status(sent) == 401 + assert b'error="invalid_token"' in _header(sent, b"www-authenticate") + assert reason in caplog.text + assert app.calls == 0 + + +async def test_invalid_token_gets_a_fixed_description_and_the_reason_is_only_logged(caplog): + app = StubApp() + api = StubApiClient(raises=VerifyAccessTokenError('Signature verification failed: detail\r\nInjected: "header"')) + + with caplog.at_level(logging.WARNING, logger="auth0_ai_mcp.middleware"): + sent = await _call(_middleware(app, api), _scope(headers=_bearer("secret-tok"))) + + assert _status(sent) == 401 + assert app.calls == 0 + assert api.verified_tokens == ["secret-tok"] + assert "Injected" not in _header(sent, b"www-authenticate").decode() + assert "Injected" not in json.dumps(_body(sent)) + assert _header(sent, b"www-authenticate") == ( + b'Bearer error="invalid_token", error_description="The access token is not valid", ' + + f'resource_metadata="{PRM_URL}"'.encode() + ) + assert _body(sent)["error"] == { + "code": -32001, + "message": "Unauthorized: The access token is not valid", + } + assert "Signature verification failed: detail" in caplog.text + assert "secret-tok" not in caplog.text + + +async def test_unreachable_auth_server_returns_503_without_a_challenge_and_skips_app(caplog): + app = StubApp() + api = StubApiClient(raises=AuthServerUnavailableError("Failed to fetch JWKS")) + + with caplog.at_level(logging.ERROR, logger="auth0_ai_mcp.middleware"): + sent = await _call(_middleware(app, api), _scope(headers=_bearer("tok"))) + + assert _status(sent) == 503 + assert _header(sent, b"retry-after") == b"5" + assert _header(sent, b"www-authenticate") is None + assert _body(sent)["error"]["code"] == -32002 + assert "Failed to fetch JWKS" not in json.dumps(_body(sent)) + assert "Failed to fetch JWKS" in caplog.text + assert app.calls == 0 + + +async def test_non_verification_errors_propagate(): + api = StubApiClient(raises=ValueError("misconfigured")) + app = StubApp() + + with pytest.raises(ValueError): + await _call(_middleware(app, api), _scope(headers=_bearer("tok"))) + + assert app.calls == 0 + + +async def test_claims_without_sub_return_401_and_skip_app(): + app = StubApp() + api = StubApiClient(claims=_claims(sub="")) + + sent = await _call(_middleware(app, api), _scope(headers=_bearer("tok"))) + + assert _status(sent) == 401 + assert app.calls == 0 + + +def test_build_www_authenticate_with_error(): + assert build_www_authenticate(PRM_URL, "invalid_token", "Expired") == ( + f'Bearer error="invalid_token", error_description="Expired", resource_metadata="{PRM_URL}"' + ) + + +def test_build_www_authenticate_sanitizes_every_value(): + challenge = build_www_authenticate('https://h/"x\\', 'bad"\r\n', 'say "hi"\\\r\nX: y \u00e9\u2603') + + assert challenge == ( + "Bearer error=\"bad' \", error_description=\"say 'hi' X: y \", " + "resource_metadata=\"https://h/'x \"" + ) + challenge.encode("ascii") + + +async def test_valid_token_runs_app_with_caller_in_context(): + app = StubApp() + api = StubApiClient(claims=_claims()) + + sent = await _call(_middleware(app, api), _scope(headers=_bearer("good"))) + + assert sent == [] + assert app.calls == 1 + assert app.seen_principal.sub == "user_1" + assert app.seen_principal.scopes == ["read:x", "write:x"] + assert api.verified_tokens == ["good"] + + +async def test_context_client_is_bound_to_the_verified_token(): + app = StubApp() + api = StubApiClient(claims=_claims()) + + await _call(_middleware(app, api), _scope(headers=_bearer("good"))) + + await app.seen_client.get_token_on_behalf_of(audience="https://orders/") + + assert app.seen_client.get_principal() is app.seen_principal + assert api.exchanged == [("good", _claims())] + + +async def test_valid_token_sets_authenticated_user_without_the_raw_token(): + app = StubApp() + api = StubApiClient(claims=_claims(iss="https://tenant.example/")) + + await _call(_middleware(app, api), _scope(headers=_bearer("raw-bearer-value"))) + + user = app.seen_scope["user"] + auth = app.seen_scope["auth"] + assert isinstance(user, AuthenticatedUser) + assert isinstance(auth, AuthCredentials) + assert user.access_token.client_id == "client_1" + assert user.access_token.subject == "user_1" + assert user.access_token.claims == {"iss": "https://tenant.example/"} + assert auth.scopes == ["read:x", "write:x"] + assert user.access_token.token != "raw-bearer-value" + assert "raw-bearer-value" not in repr(vars(user)) + user.access_token.model_dump_json() + assert "raw-bearer-value" not in repr(vars(auth)) + + +async def test_rejected_token_sets_no_user_on_the_scope(): + scope = _scope(headers=_bearer("bad")) + + await _call(_middleware(StubApp(), StubApiClient(raises=VerifyAccessTokenError("x"))), scope) + + assert "user" not in scope and "auth" not in scope + + +_SESSION_CLAIMS = { + "alice-1": {"sub": "alice", "azp": "c1", "iss": "https://t/", "exp": 4102444800}, + "alice-2": {"sub": "alice", "azp": "c1", "iss": "https://t/", "exp": 4102444900}, + "bob-1": {"sub": "bob", "azp": "c1", "iss": "https://t/", "exp": 4102444800}, +} + + +class StubTokenTableApiClient: + """Maps each bearer value to its own claim set.""" + + options = SimpleNamespace(domain=DOMAIN, audience=RESOURCE_URL, domains=None) + + async def verify_access_token(self, access_token): + return _SESSION_CLAIMS[access_token] + + +async def test_mcp_session_stays_bound_to_the_caller_that_opened_it(): + manager = StreamableHTTPSessionManager(app=Server("test"), json_response=True) + middleware = Auth0AsgiMiddleware( + manager.handle_request, + mcp_client=McpServerClient(StubTokenTableApiClient()), + ) + headers = {"accept": "application/json, text/event-stream", "content-type": "application/json"} + initialize = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}, + }, + } + ping = {"jsonrpc": "2.0", "id": 2, "method": "ping"} + + async with manager.run(), httpx.AsyncClient( + transport=httpx.ASGITransport(app=middleware), base_url="http://localhost" + ) as http: + opened = await http.post( + "/mcp", headers={**headers, "authorization": "Bearer alice-1"}, json=initialize + ) + session = {"mcp-session-id": opened.headers["mcp-session-id"], "mcp-protocol-version": "2025-06-18"} + + other = await http.post( + "/mcp", headers={**headers, **session, "authorization": "Bearer bob-1"}, json=ping + ) + same = await http.post( + "/mcp", headers={**headers, **session, "authorization": "Bearer alice-2"}, json=ping + ) + + assert opened.status_code == 200 + assert other.status_code == 404 + assert same.status_code == 200 + + +async def test_context_is_reset_after_request(): + await _call( + _middleware(StubApp(), StubApiClient(claims=_claims())), + _scope(headers=_bearer("good")), + ) + + assert get_caller_principal() is None + + +async def test_context_is_reset_when_app_raises(): + app = StubApp(raises=RuntimeError("handler failed")) + middleware = _middleware(app, StubApiClient(claims=_claims())) + + with pytest.raises(RuntimeError): + await _call(middleware, _scope(headers=_bearer("good"))) + + assert app.calls == 1 + assert get_caller_principal() is None + + +async def test_lifespan_scope_passes_through_without_verification(): + app = StubApp() + api = StubApiClient(claims=_claims()) + + sent = await _call(_middleware(app, api), {"type": "lifespan"}) + + assert sent == [] + assert app.calls == 1 + assert api.verified_tokens == [] + + +async def test_websocket_scope_is_closed_without_running_the_app(): + app = StubApp() + api = StubApiClient(claims=_claims()) + + sent = await _call(_middleware(app, api), _scope(scope_type="websocket", headers=_bearer("good"))) + + assert sent == [{"type": "websocket.close", "code": 1008}] + assert app.calls == 0 + assert api.verified_tokens == [] + + +async def test_options_preflight_passes_through_without_verification_or_context(): + app = StubApp() + api = StubApiClient(claims=_claims()) + + sent = await _call(_middleware(app, api), _scope(method="OPTIONS")) + + assert sent == [] + assert app.calls == 1 + assert app.seen_principal is None + assert api.verified_tokens == [] + + +async def test_metadata_route_is_served_without_a_token(): + app = StubApp() + api = StubApiClient(claims=_claims()) + + sent = await _call( + _middleware(app, api, scopes_supported=["read:x"]), + _scope(path="/.well-known/oauth-protected-resource", method="GET"), + ) + + assert _status(sent) == 200 + assert _body(sent) == { + "resource": RESOURCE_URL, + "authorization_servers": [f"https://{DOMAIN}/"], + "bearer_methods_supported": ["header"], + "scopes_supported": ["read:x"], + } + assert app.calls == 0 + assert api.verified_tokens == [] + + +def test_metadata_omits_scopes_supported_when_not_given(): + doc = build_protected_resource_metadata(McpServerClient(StubApiClient())) + + assert "scopes_supported" not in doc + + +def test_metadata_omits_resource_name_and_documentation_when_not_given(): + doc = build_protected_resource_metadata(McpServerClient(StubApiClient())) + + assert "resource_name" not in doc + assert "resource_documentation" not in doc + assert "jwks_uri" not in doc + + +async def test_metadata_includes_resource_name_and_documentation_when_given(): + sent = await _call( + _middleware( + StubApp(), + StubApiClient(claims=_claims()), + resource_name="Orders MCP", + resource_documentation="https://docs.example/orders", + ), + _scope(path="/.well-known/oauth-protected-resource", method="GET"), + ) + + body = _body(sent) + assert body["resource_name"] == "Orders MCP" + assert body["resource_documentation"] == "https://docs.example/orders" + + +async def test_metadata_is_served_at_the_path_suffixed_route(): + app = StubApp() + + sent = await _call( + _middleware(app, StubApiClient(claims=_claims())), + _scope(path="/.well-known/oauth-protected-resource/mcp", method="GET"), + ) + + assert _status(sent) == 200 + assert _body(sent)["resource"] == RESOURCE_URL + assert app.calls == 0 + + +async def test_metadata_route_keeps_a_trailing_slash_in_the_resource_path(): + middleware = Auth0AsgiMiddleware( + StubApp(), + mcp_client=McpServerClient(StubApiClient(claims=_claims(), audience="https://mcp.example/mcp/")), + ) + + served = await _call(middleware, _scope(path="/.well-known/oauth-protected-resource/mcp/", method="GET")) + challenged = await _call(middleware, _scope()) + + assert _status(served) == 200 + assert _header(challenged, b"www-authenticate") == ( + b'Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource/mcp/"' + ) + + +@pytest.mark.parametrize( + "path", ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"] +) +async def test_metadata_routes_need_a_token_for_methods_other_than_get(path): + app = StubApp() + api = StubApiClient(claims=_claims()) + + sent = await _call(_middleware(app, api), _scope(path=path, method="POST")) + + assert _status(sent) == 401 + assert app.calls == 0 + + +BUILDERS = [ + lambda client: build_protected_resource_metadata(client), + lambda client: Auth0AsgiMiddleware(StubApp(), mcp_client=client), +] + + +@pytest.mark.parametrize( + "api_options", + [{"domain": ""}, {"domain": None}, {"domain": None, "domains": ["a.example", "b.example"]}], + ids=["empty", "none", "multi_domain"], +) +@pytest.mark.parametrize("build", BUILDERS) +def test_a_client_without_a_single_domain_needs_explicit_authorization_servers(build, api_options): + with pytest.raises(ConfigurationError, match="authorization_servers is required"): + build(McpServerClient(StubApiClient(**api_options))) + + +@pytest.mark.parametrize( + "domain", + [ + " ", + "tenant.example/path", + "http://tenant.example", + "https://tenant.example/", + "tenant.example/", + " tenant.example", + "tenant.example\n", + "tenant .example", + ], +) +def test_a_client_domain_that_is_not_a_bare_tenant_host_is_rejected(domain): + with pytest.raises(ConfigurationError, match="tenant domain|bare host"): + build_protected_resource_metadata(McpServerClient(StubApiClient(domain=domain))) + + +def test_a_domain_the_core_client_cannot_discover_from_is_rejected_for_a_real_client(): + client = create_mcp_client("https://tenant.example/", RESOURCE_URL) + + with pytest.raises(ConfigurationError, match="bare host"): + build_protected_resource_metadata(client) + + +@pytest.mark.parametrize( + "audience", + [ + "", + "orders-api", + "/mcp", + "mcp.example/mcp", + "ftp://mcp.example/mcp", + "https:///mcp", + "https://mcp.example/mcp#x", + "https://user:pass@mcp.example/mcp", + "https://mcp.example/m cp", + "https://mcp.ex\tample/mcp", + None, + ], +) +def test_an_audience_that_is_not_an_absolute_http_url_is_rejected(audience): + with pytest.raises(ConfigurationError, match="resource_url"): + build_protected_resource_metadata(McpServerClient(StubApiClient(audience=audience))) + + +def test_the_middleware_rejects_an_invalid_audience_too(): + with pytest.raises(ConfigurationError, match="resource_url"): + Auth0AsgiMiddleware(StubApp(), mcp_client=McpServerClient(StubApiClient(audience="orders-api"))) + + +@pytest.mark.parametrize("resource_url", ["https://mcp.example/other", "https://mcp.example/mcp/", "http://mcp.example/mcp"]) +@pytest.mark.parametrize( + "build", + [ + lambda client, url: build_protected_resource_metadata(client, resource_url=url), + lambda client, url: Auth0AsgiMiddleware(StubApp(), mcp_client=client, resource_url=url), + ], +) +def test_a_resource_url_that_differs_from_the_audience_is_rejected(build, resource_url): + with pytest.raises(ConfigurationError, match="equal the client audience"): + build(McpServerClient(StubApiClient()), resource_url) + + +def test_an_explicit_resource_url_equal_to_the_audience_is_accepted(): + doc = build_protected_resource_metadata(McpServerClient(StubApiClient()), resource_url=RESOURCE_URL) + + assert doc["resource"] == RESOURCE_URL + + +@pytest.mark.parametrize( + "domain, expected", + [ + ("tenant.example", "https://tenant.example/"), + ("Tenant.Example", "https://tenant.example/"), + ("tenant.example:8443", "https://tenant.example:8443/"), + ], +) +def test_the_default_authorization_server_is_the_normalized_client_domain(domain, expected): + doc = build_protected_resource_metadata(McpServerClient(StubApiClient(domain=domain))) + + assert doc["authorization_servers"] == [expected] + + +def test_an_http_resource_with_a_port_is_accepted(): + doc = build_protected_resource_metadata(McpServerClient(StubApiClient(audience="http://localhost:3000/mcp"))) + + assert doc["resource"] == "http://localhost:3000/mcp" + + +@pytest.mark.parametrize("api_options", [{}, {"domain": None, "domains": ["a.example", "b.example"]}], ids=["single", "multi"]) +def test_explicit_authorization_servers_are_advertised_as_given(api_options): + servers = ["https://login.example/", "https://other.example/"] + + doc = build_protected_resource_metadata(McpServerClient(StubApiClient(**api_options)), authorization_servers=servers) + + assert doc["authorization_servers"] == servers + + +@pytest.mark.parametrize( + "servers", + [ + [], + "https://login.example/", + ["login.example"], + ["http://login.example/"], + ["https:///"], + ["https://login.example/#x"], + ["https://login.example/?q=1"], + ["https://user:pass@login.example/"], + ["https://login .example/"], + ["https://login.example/\x00"], + [None], + ], +) +def test_authorization_servers_must_be_a_non_empty_list_of_https_urls(servers): + with pytest.raises(ConfigurationError, match="authorization_servers"): + build_protected_resource_metadata(McpServerClient(StubApiClient()), authorization_servers=servers) + + +async def test_the_middleware_advertises_the_authorization_servers_it_is_given(): + middleware = _middleware(StubApp(), StubApiClient(), authorization_servers=["https://login.example/"]) + + sent = await _call(middleware, _scope(path="/.well-known/oauth-protected-resource", method="GET")) + + assert _body(sent)["authorization_servers"] == ["https://login.example/"] diff --git a/packages/auth0-ai-mcp/tests/test_principal.py b/packages/auth0-ai-mcp/tests/test_principal.py new file mode 100644 index 0000000..b9aae59 --- /dev/null +++ b/packages/auth0-ai-mcp/tests/test_principal.py @@ -0,0 +1,135 @@ +import time + +import pytest +from auth0_api_python.errors import VerifyAccessTokenError + +from auth0_ai_mcp import Principal +from auth0_ai_mcp.principal import build_principal + + +def test_build_principal_with_all_claims_present(): + claims = { + "sub": "auth0|user123", + "exp": int(time.time()) + 3600, + "scope": "calendar:read calendar:write", + "permissions": ["calendar:read", "calendar:write"], + "client_id": "my_client_id", + "org_id": "org_abc123", + } + + principal = build_principal(claims) + + assert principal == Principal( + sub="auth0|user123", + expires_at=claims["exp"], + scopes=["calendar:read", "calendar:write"], + permissions=["calendar:read", "calendar:write"], + client_id="my_client_id", + org_id="org_abc123", + ) + + +def test_build_principal_permissions_present_but_empty(): + claims = { + "sub": "auth0|user123", + "exp": int(time.time()) + 3600, + "azp": "spa_client_id", + "permissions": [], + } + + principal = build_principal(claims) + + assert principal.permissions == [] + + +def test_build_principal_missing_both_org_id_and_permissions(): + claims = { + "sub": "auth0|user123", + "exp": int(time.time()) + 3600, + "azp": "spa_client_id", + } + + principal = build_principal(claims) + + assert principal.org_id is None + assert principal.permissions is None + + +def test_build_principal_missing_scope_claim_yields_empty_list(): + claims = { + "sub": "auth0|user123", + "exp": int(time.time()) + 3600, + "azp": "spa_client_id", + } + + principal = build_principal(claims) + + assert principal.scopes == [] + + +_ABSENT = object() + + +def _claims(**overrides): + claims = {"sub": "auth0|user123", "exp": int(time.time()) + 3600, "azp": "spa_client_id"} + claims.update(overrides) + return {k: v for k, v in claims.items() if v is not _ABSENT} + + +@pytest.mark.parametrize("sub", [_ABSENT, " ", "\t\n", 123, None]) +def test_build_principal_rejects_blank_or_non_string_sub(sub): + with pytest.raises(VerifyAccessTokenError, match="sub"): + build_principal(_claims(sub=sub)) + + +def test_build_principal_prefers_azp_over_client_id(): + principal = build_principal(_claims(azp="azp_value", client_id="client_id_value")) + + assert principal.client_id == "azp_value" + + +@pytest.mark.parametrize("azp", [_ABSENT, None, "", " ", 42]) +def test_build_principal_falls_back_to_client_id_when_azp_is_unusable(azp): + principal = build_principal(_claims(azp=azp, client_id="client_id_value")) + + assert principal.client_id == "client_id_value" + + +@pytest.mark.parametrize( + ("azp", "client_id"), + [(_ABSENT, _ABSENT), (None, None), (" ", " "), (42, 42), ("", ["c"])], +) +def test_build_principal_rejects_claims_without_a_usable_client(azp, client_id): + with pytest.raises(VerifyAccessTokenError, match="client_id"): + build_principal(_claims(azp=azp, client_id=client_id)) + + +@pytest.mark.parametrize("exp", [_ABSENT, None, "4102444800", True, False, float("nan"), float("inf"), [1]]) +def test_build_principal_rejects_missing_or_non_numeric_exp(exp): + with pytest.raises(VerifyAccessTokenError, match="exp"): + build_principal(_claims(exp=exp)) + + +def test_build_principal_accepts_a_float_exp(): + assert build_principal(_claims(exp=4102444800.5)).expires_at == 4102444800.5 + + +@pytest.mark.parametrize("permissions", ["read:x", {"read:x": True}, ["read:x", 1], [None]]) +def test_build_principal_rejects_permissions_that_are_not_a_list_of_strings(permissions): + with pytest.raises(VerifyAccessTokenError, match="permissions"): + build_principal(_claims(permissions=permissions)) + + +def test_build_principal_treats_null_permissions_as_absent(): + assert build_principal(_claims(permissions=None)).permissions is None + + +@pytest.mark.parametrize("org_id", [123, ["org_abc"], {"id": "org_abc"}, True]) +def test_build_principal_rejects_non_string_org_id(org_id): + with pytest.raises(VerifyAccessTokenError, match="org_id"): + build_principal(_claims(org_id=org_id)) + + +@pytest.mark.parametrize("scope", [["read:x"], 123, {"read:x": True}]) +def test_build_principal_non_string_scope_yields_empty_list(scope): + assert build_principal(_claims(scope=scope)).scopes == [] diff --git a/packages/auth0-ai-mcp/tests/test_request_context.py b/packages/auth0-ai-mcp/tests/test_request_context.py new file mode 100644 index 0000000..06bb4b0 --- /dev/null +++ b/packages/auth0-ai-mcp/tests/test_request_context.py @@ -0,0 +1,64 @@ +"""Tests for the request context binding and the get_caller_* accessors.""" + +import pytest + +from auth0_ai_mcp import ( + Principal, + RequestContext, + UnauthenticatedError, + bind_request_context, + get_caller_mcp_client, + get_caller_principal, + require_caller_mcp_client, + require_caller_principal, +) + + +def _context() -> RequestContext: + principal = Principal( + sub="u1", expires_at=9999999999, scopes=[], permissions=None, client_id="client_1", org_id=None + ) + # OBO is not exercised here, so a plain object stands in for the client. + return RequestContext(principal=principal, mcp_client=object()) # type: ignore[arg-type] + + +def test_context_is_set_inside_the_block(): + ctx = _context() + with bind_request_context(ctx): + assert get_caller_principal() is ctx.principal + assert get_caller_mcp_client() is ctx.mcp_client + + +def test_context_is_reset_after_normal_exit(): + with bind_request_context(_context()): + pass + assert get_caller_principal() is None + + +def test_context_is_reset_after_an_exception(): + with pytest.raises(RuntimeError), bind_request_context(_context()): + raise RuntimeError("boom") + assert get_caller_principal() is None + + +def test_require_caller_principal_returns_principal_inside_a_request(): + ctx = _context() + with bind_request_context(ctx): + assert require_caller_principal() is ctx.principal + + +def test_require_caller_principal_raises_unauthenticated_outside_a_request(): + with pytest.raises(UnauthenticatedError): + require_caller_principal() + + +def test_require_caller_mcp_client_returns_client_inside_a_request(): + ctx = _context() + with bind_request_context(ctx): + assert require_caller_mcp_client() is ctx.mcp_client + + +def test_require_caller_mcp_client_raises_unauthenticated_outside_a_request(): + assert get_caller_mcp_client() is None + with pytest.raises(UnauthenticatedError): + require_caller_mcp_client() diff --git a/packages/auth0-ai-mcp/tests/test_scopes.py b/packages/auth0-ai-mcp/tests/test_scopes.py new file mode 100644 index 0000000..fcfb53b --- /dev/null +++ b/packages/auth0-ai-mcp/tests/test_scopes.py @@ -0,0 +1,152 @@ +"""Tests for require_scopes and the with_required_scopes decorator.""" + +import inspect + +import pytest +from auth0_api_python.errors import ConfigurationError + +from auth0_ai_mcp import Principal +from auth0_ai_mcp.errors import InsufficientScopeError, UnauthenticatedError +from auth0_ai_mcp.request_context import RequestContext, reset_request_context, set_request_context +from auth0_ai_mcp.scopes import require_scopes, with_required_scopes + + +def _principal(scopes: list[str]) -> Principal: + return Principal( + sub="u1", + expires_at=9999999999, + scopes=scopes, + permissions=None, + client_id="client_1", + org_id=None, + ) + + +def _make_context(principal: Principal) -> RequestContext: + # mcp_client is required by the dataclass. Pass a plain object since OBO is not tested here. + return RequestContext(principal=principal, mcp_client=object()) # type: ignore[arg-type] + + +def test_passes_when_principal_has_all_scopes(): + require_scopes(_principal(["read:x", "write:y"]), ["read:x", "write:y"]) + + +def test_passes_with_superset_of_required_scopes(): + require_scopes(_principal(["read:x", "write:y", "admin"]), ["read:x"]) + + +def test_single_string_is_one_scope(): + require_scopes(_principal(["read:x"]), "read:x") + + +@pytest.mark.parametrize("required", ["write:y", ["read:x", "write:y"]]) +def test_raises_insufficient_scope_when_any_required_scope_is_missing(required): + with pytest.raises(InsufficientScopeError) as exc_info: + require_scopes(_principal(["read:x"]), required) + + assert exc_info.value.get_status_code() == 403 + assert exc_info.value.get_error_code() == "insufficient_scope" + + +def test_scope_held_only_as_a_permission_does_not_satisfy_the_check(): + principal = Principal( + sub="u1", expires_at=9999999999, scopes=[], permissions=["read:x"], client_id="client_1", org_id=None + ) + + with pytest.raises(InsufficientScopeError): + require_scopes(principal, "read:x") + + +def test_insufficient_scope_message_does_not_leak_scope(): + with pytest.raises(InsufficientScopeError) as exc_info: + require_scopes(_principal(["read:x"]), ["read:x", "write:y"]) + assert "write:y" not in str(exc_info.value) + + +def test_raises_unauthenticated_when_principal_is_none(): + with pytest.raises(UnauthenticatedError) as exc_info: + require_scopes(None, "read:x") + + assert exc_info.value.get_status_code() == 401 + assert exc_info.value.get_error_code() == "invalid_token" + + +@pytest.mark.parametrize("scopes", [[], "", " ", ["read:x", ""], ["read:x", " "], ["read:x", 1], None, ("read:x",)]) +def test_rejects_empty_or_malformed_scopes(scopes: object): + with pytest.raises(ConfigurationError): + require_scopes(_principal(["read:x"]), scopes) # type: ignore[arg-type] + + +@pytest.mark.parametrize(("required", "held"), [("read:x", ["read:x"]), (["read:x", "write:y"], ["read:x", "write:y"])]) +async def test_handler_runs_when_principal_has_every_required_scope(required, held): + ctx = _make_context(_principal(held)) + token = set_request_context(ctx) + try: + @with_required_scopes(required) + async def handler() -> str: + return "ok" + + result = await handler() + assert result == "ok" + finally: + reset_request_context(token) + + +async def test_args_and_kwargs_pass_through(): + ctx = _make_context(_principal(["read:x"])) + token = set_request_context(ctx) + try: + @with_required_scopes("read:x") + async def handler(a: int, *, b: str) -> tuple: + return (a, b) + + assert await handler(1, b="hello") == (1, "hello") + finally: + reset_request_context(token) + + +async def test_raises_insufficient_scope_and_inner_body_does_not_run(): + ctx = _make_context(_principal(["read:x"])) + token = set_request_context(ctx) + try: + inner_ran = False + + @with_required_scopes("write:y") + async def handler() -> None: + nonlocal inner_ran + inner_ran = True + + with pytest.raises(InsufficientScopeError): + await handler() + + # The guard must have fired before the body. inner_ran must still be False. + assert inner_ran is False + finally: + reset_request_context(token) + + +@pytest.mark.parametrize("scopes", [[], "", ["read:x", ""], ["read:x", 1]]) +def test_rejects_empty_or_malformed_scopes_at_decoration_time(scopes: object): + with pytest.raises(ConfigurationError): + with_required_scopes(scopes) # type: ignore[arg-type] + + +async def test_raises_unauthenticated_when_no_context_is_set(): + # Do not set any context. The contextvar defaults to None. + @with_required_scopes("read:x") + async def handler() -> None: + pass # pragma: no cover + + with pytest.raises(UnauthenticatedError): + await handler() + + +def test_decorated_handler_keeps_its_name_doc_and_signature(): + @with_required_scopes("read:x") + async def handler(a: int, *, b: str = "x") -> str: + """Handler doc.""" + return b + + assert handler.__name__ == "handler" + assert handler.__doc__ == "Handler doc." + assert list(inspect.signature(handler).parameters) == ["a", "b"] From 0270b1f2c43d8c132bdf9c0cb5f507d30a73d98b Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Fri, 30 Oct 2026 13:14:13 +0530 Subject: [PATCH 2/7] test: pass the metadata builder directly in middleware tests --- packages/auth0-ai-mcp/tests/test_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth0-ai-mcp/tests/test_middleware.py b/packages/auth0-ai-mcp/tests/test_middleware.py index 920d41b..25fd87e 100644 --- a/packages/auth0-ai-mcp/tests/test_middleware.py +++ b/packages/auth0-ai-mcp/tests/test_middleware.py @@ -543,7 +543,7 @@ async def test_metadata_routes_need_a_token_for_methods_other_than_get(path): BUILDERS = [ - lambda client: build_protected_resource_metadata(client), + build_protected_resource_metadata, lambda client: Auth0AsgiMiddleware(StubApp(), mcp_client=client), ] From 210f67d49c300533e3c960e03250fa5e22eea0bf Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Mon, 2 Nov 2026 20:55:52 +0530 Subject: [PATCH 3/7] fix: address review feedback on auth0-ai-mcp --- .github/workflows/test.yml | 2 +- packages/auth0-ai-mcp/README.md | 23 ++++- packages/auth0-ai-mcp/poetry.lock | 82 +++++++++------- packages/auth0-ai-mcp/pyproject.toml | 4 + .../auth0-ai-mcp/src/auth0_ai_mcp/__init__.py | 4 + .../auth0-ai-mcp/src/auth0_ai_mcp/client.py | 23 +++-- .../auth0-ai-mcp/src/auth0_ai_mcp/errors.py | 11 ++- .../src/auth0_ai_mcp/middleware.py | 9 +- .../src/auth0_ai_mcp/principal.py | 2 +- .../auth0-ai-mcp/src/auth0_ai_mcp/scopes.py | 12 ++- .../utils/_internal/scope_input.py | 4 +- packages/auth0-ai-mcp/tests/test_client.py | 32 ++++++- .../auth0-ai-mcp/tests/test_middleware.py | 86 ++++++++++++++++- packages/auth0-ai-mcp/tests/test_principal.py | 7 ++ .../tests/test_request_context.py | 25 +++++ packages/auth0-ai-mcp/tests/test_scopes.py | 94 ++++++++++++++++++- 16 files changed, 363 insertions(+), 57 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6ac281b..ef6d46b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: strategy: matrix: package: [packages/auth0-ai-mcp] - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout code diff --git a/packages/auth0-ai-mcp/README.md b/packages/auth0-ai-mcp/README.md index 8d35d5a..01e4153 100644 --- a/packages/auth0-ai-mcp/README.md +++ b/packages/auth0-ai-mcp/README.md @@ -29,7 +29,13 @@ Or with Poetry: poetry add auth0-ai-mcp ``` -Python 3.10 or later is required. +Python 3.10 or later is required. The examples below use the official `mcp` SDK, so install it with the `mcp` extra: + +```bash +pip install "auth0-ai-mcp[mcp]" +``` + +The snippets use the 2.x import `mcp.server.mcpserver` and were tested with `mcp` 2.3. ### 2. Build the client @@ -54,20 +60,30 @@ client = create_mcp_client( The ASGI adapter wraps any ASGI app and handles all auth work before the MCP handler runs: ```python +from mcp.server.mcpserver import MCPServer +from mcp.server.transport_security import TransportSecuritySettings + from auth0_ai_mcp.middleware import Auth0AsgiMiddleware +mcp = MCPServer("weather") + secured_app = Auth0AsgiMiddleware( - app, + mcp.streamable_http_app( + transport_security=TransportSecuritySettings(allowed_hosts=["your-mcp-server", "your-mcp-server:*"]), + ), mcp_client=client, + scopes_supported=["read:weather"], ) ``` +The `mcp` SDK only accepts requests for `127.0.0.1`, `localhost` and `::1` by default, so list your public host in `allowed_hosts`, with and without a port, or every request gets a `421`. `scopes_supported` is advertised in the metadata, so clients know to request the scopes your tools need. Without it a client may ask for no scope and every gated tool fails. ### 4. Write a gated tool ```python from auth0_ai_mcp import require_caller_mcp_client, with_required_scopes +@mcp.tool() @with_required_scopes("read:weather") async def get_weather(location: str) -> dict: auth0 = require_caller_mcp_client() @@ -76,9 +92,10 @@ async def get_weather(location: str) -> dict: scope="read:weather", ) # call the downstream API with token["access_token"] + ... ``` -`with_required_scopes` raises before the tool body runs if the caller's token is missing a listed scope. `require_caller_mcp_client` reads the request-scoped client from the `contextvar` set by the adapter. +`with_required_scopes` raises before the tool body runs if the caller's token is missing a listed scope. Keep `@mcp.tool()` as the top decorator. If it sits below, the framework registers the unguarded function and the check is skipped. `require_caller_mcp_client` reads the request-scoped client from the `contextvar` set by the adapter. ## Feedback diff --git a/packages/auth0-ai-mcp/poetry.lock b/packages/auth0-ai-mcp/poetry.lock index a8f1def..6163d8e 100644 --- a/packages/auth0-ai-mcp/poetry.lock +++ b/packages/auth0-ai-mcp/poetry.lock @@ -60,11 +60,12 @@ version = "0.8.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, ] +markers = {main = "extra == \"mcp\""} [[package]] name = "anyio" @@ -92,11 +93,12 @@ version = "26.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] +markers = {main = "extra == \"mcp\""} [[package]] name = "auth0-api-python" @@ -465,12 +467,12 @@ version = "8.5.0" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["dev"] -markers = "sys_platform != \"emscripten\"" +groups = ["main", "dev"] files = [ {file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"}, {file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"}, ] +markers = {main = "extra == \"mcp\" and sys_platform != \"emscripten\"", dev = "sys_platform != \"emscripten\""} [[package]] name = "colorama" @@ -608,12 +610,12 @@ version = "2.13.0" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.10" -groups = ["dev"] -markers = "sys_platform != \"emscripten\"" +groups = ["main", "dev"] files = [ {file = "httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e"}, {file = "httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db"}, ] +markers = {main = "extra == \"mcp\" and sys_platform != \"emscripten\"", dev = "sys_platform != \"emscripten\""} [package.dependencies] h11 = ">=0.16" @@ -656,11 +658,12 @@ version = "2.13.0" description = "The next generation HTTP client." optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a"}, {file = "httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] anyio = {version = ">=4.10", markers = "sys_platform != \"emscripten\""} @@ -684,12 +687,12 @@ version = "1.0" description = "httpx2 transports for Emscripten/Pyodide, backed by the JavaScript fetch API." optional = false python-versions = ">=3.12" -groups = ["dev"] -markers = "sys_platform == \"emscripten\" and python_version >= \"3.12\"" +groups = ["main", "dev"] files = [ {file = "httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32"}, {file = "httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60"}, ] +markers = {main = "sys_platform == \"emscripten\" and python_version >= \"3.12\" and extra == \"mcp\"", dev = "sys_platform == \"emscripten\" and python_version >= \"3.12\""} [[package]] name = "idna" @@ -742,11 +745,12 @@ version = "4.26.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] attrs = ">=22.2.0" @@ -764,11 +768,12 @@ version = "2025.9.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] referencing = ">=0.31.0" @@ -795,11 +800,12 @@ version = "2.3.0" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377"}, {file = "mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] anyio = [ @@ -833,11 +839,12 @@ version = "2.3.0" description = "Model Context Protocol wire types" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d"}, {file = "mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] pydantic = ">=2.12.0" @@ -849,11 +856,12 @@ version = "1.45.2" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "opentelemetry_api-1.45.2-py3-none-any.whl", hash = "sha256:2cbd737377e710ee740d261c94e93fa0f08e6d037c18f17513cc0a2d94479a34"}, {file = "opentelemetry_api-1.45.2.tar.gz", hash = "sha256:d7b0008e21e5fd61cbe7a7f34ca3b6905a78ab677d9bc395e299359c75cda6b4"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] typing-extensions = ">=4.5.0" @@ -905,11 +913,12 @@ version = "2.13.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73"}, {file = "pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] annotated-types = ">=0.6.0" @@ -927,7 +936,7 @@ version = "2.46.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6"}, {file = "pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615"}, @@ -1050,6 +1059,7 @@ files = [ {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266"}, {file = "pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] typing-extensions = ">=4.14.1" @@ -1075,11 +1085,12 @@ version = "2.15.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193"}, {file = "pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} @@ -1154,11 +1165,12 @@ version = "0.0.32" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"}, {file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"}, ] +markers = {main = "extra == \"mcp\""} [[package]] name = "pywin32" @@ -1166,8 +1178,7 @@ version = "312" description = "Python for Windows Extensions" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "sys_platform == \"win32\"" +groups = ["main", "dev"] files = [ {file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"}, {file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"}, @@ -1191,6 +1202,7 @@ files = [ {file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"}, {file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"}, ] +markers = {main = "extra == \"mcp\" and sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} [[package]] name = "referencing" @@ -1198,11 +1210,12 @@ version = "0.37.0" description = "JSON Referencing + Python" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] attrs = ">=22.2.0" @@ -1237,8 +1250,7 @@ version = "0.30.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version < \"3.14\"" +groups = ["main", "dev"] files = [ {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, @@ -1356,6 +1368,7 @@ files = [ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, ] +markers = {main = "python_version < \"3.14\" and extra == \"mcp\"", dev = "python_version < \"3.14\""} [[package]] name = "rpds-py" @@ -1363,8 +1376,7 @@ version = "2026.6.3" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.11" -groups = ["dev"] -markers = "python_version >= \"3.14\"" +groups = ["main", "dev"] files = [ {file = "rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7"}, {file = "rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911"}, @@ -1483,6 +1495,7 @@ files = [ {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826"}, {file = "rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4"}, ] +markers = {main = "python_version >= \"3.14\" and extra == \"mcp\"", dev = "python_version >= \"3.14\""} [[package]] name = "ruff" @@ -1518,11 +1531,12 @@ version = "3.4.11" description = "SSE plugin for Starlette" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453"}, {file = "sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] anyio = ">=4.7.0" @@ -1638,12 +1652,12 @@ version = "0.10.4" description = "Verify certificates using native system trust stores" optional = false python-versions = ">=3.10" -groups = ["dev"] -markers = "sys_platform != \"emscripten\"" +groups = ["main", "dev"] files = [ {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, ] +markers = {main = "extra == \"mcp\" and sys_platform != \"emscripten\"", dev = "sys_platform != \"emscripten\""} [[package]] name = "typing-extensions" @@ -1663,11 +1677,12 @@ version = "0.4.4" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, ] +markers = {main = "extra == \"mcp\""} [package.dependencies] typing-extensions = ">=4.15.0" @@ -1696,12 +1711,12 @@ version = "0.52.4" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.10" -groups = ["dev"] -markers = "sys_platform != \"emscripten\"" +groups = ["main", "dev"] files = [ {file = "uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1"}, {file = "uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86"}, ] +markers = {main = "extra == \"mcp\" and sys_platform != \"emscripten\"", dev = "sys_platform != \"emscripten\""} [package.dependencies] click = ">=7.0" @@ -1711,7 +1726,10 @@ typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] standard = ["httptools (>=0.8.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=13.0)"] +[extras] +mcp = ["mcp"] + [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "6b895636a3b5b36034da2aae7fa0ec05bc2d83d57937ca78ccfea13ae42f9d24" +content-hash = "407e01acf42d671cc427d9991f583eb64dce046c5a6bcb55b2d83f7be1ed45cf" diff --git a/packages/auth0-ai-mcp/pyproject.toml b/packages/auth0-ai-mcp/pyproject.toml index 1459129..1380f7d 100644 --- a/packages/auth0-ai-mcp/pyproject.toml +++ b/packages/auth0-ai-mcp/pyproject.toml @@ -12,6 +12,10 @@ python = ">=3.10,<4.0" # Pinned by SHA until a core release includes the OBO, M2M and Token Vault caching, the malformed-token fix and client_info. auth0-api-python = { git = "https://github.com/auth0/auth0-api-python.git", rev = "1fe1a417e867d417b4bd44c5984fe002f11149f9" } starlette = ">=0.37.0" +mcp = { version = ">=2.2.0", optional = true } + +[tool.poetry.extras] +mcp = ["mcp"] [tool.poetry.group.dev.dependencies] mcp = "^2.2.0" diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/__init__.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/__init__.py index 2c09861..9582c5d 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/__init__.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/__init__.py @@ -21,7 +21,9 @@ from auth0_api_python.errors import ( ApiError, AuthServerUnavailableError, + BaseAuthError, ConfigurationError, + DomainsResolverError, GetTokenByExchangeProfileError, MissingOrganizationError, MissingRequiredArgumentError, @@ -53,7 +55,9 @@ "ApiClientOptions", "ApiError", "AuthServerUnavailableError", + "BaseAuthError", "ConfigurationError", + "DomainsResolverError", "DownscopedTokenError", "GetAccessTokenForConnectionError", "GetClientCredentialsTokenError", diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/client.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/client.py index 0a19c94..f67e366 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/client.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/client.py @@ -22,6 +22,7 @@ from auth0_api_python.errors import ( ConfigurationError, GetTokenByExchangeProfileError, + VerifyAccessTokenError, ) from .principal import Principal @@ -53,8 +54,15 @@ def with_token(self, verified: VerifiedToken, principal: Principal) -> RequestSc return RequestScopedMcpClient(self, verified, principal) async def verify_access_token(self, access_token: str) -> VerifiedToken: - """Verify the incoming token, returning it paired with its verified claims.""" + """Verify the incoming token, returning it paired with its verified claims. + + Raises: + VerifyAccessTokenError: If the token is invalid, or is bound to a DPoP key or a client certificate (it has a `cnf` claim) and so cannot be used as a Bearer token. + AuthServerUnavailableError: If the OIDC discovery document or JWKS cannot be reached. + """ claims = await self._api.verify_access_token(access_token=access_token) + if claims.get("cnf"): + raise VerifyAccessTokenError("Sender-constrained token cannot be used as a Bearer token") return VerifiedToken(access_token=access_token, claims=claims) async def get_token_on_behalf_of( @@ -67,8 +75,9 @@ async def get_token_on_behalf_of( """Exchange the caller's token for a downstream token for the given audience and scope. Raises: + ApiError: If the token endpoint is unreachable, times out, or returns an error. DownscopedTokenError: If the exchange grants fewer scopes than were requested. - GetTokenByExchangeProfileError: If the exchanged token is already expired. + GetTokenByExchangeProfileError: If client credentials are not configured or the exchanged token is already expired. """ result = await self._api.get_token_on_behalf_of( verified.access_token, audience, scope, verified=verified @@ -87,8 +96,8 @@ async def get_client_credentials_token( scope: Optional space-separated scopes to request. Raises: - GetClientCredentialsTokenError: If client credentials are not configured, the token endpoint is unreachable, or the returned token is already expired. - ApiError: If the token endpoint returns an error. + GetClientCredentialsTokenError: If client credentials are not configured or the returned token is already expired. + ApiError: If the token endpoint is unreachable, times out, or returns an error. DownscopedTokenError: If the token endpoint grants fewer scopes than were requested. """ result = await self._api.get_client_credentials_token(audience=audience, scope=scope) @@ -108,11 +117,11 @@ async def get_token_for_connection( Args: verified: The verified incoming token for the requesting user. connection: The Auth0 federated connection identifier (e.g. "google-oauth2"). - login_hint: Optional email to pre-fill the federated login prompt. + login_hint: Optional value that selects which linked account to use when the user has several linked accounts on this connection. Raises: - GetAccessTokenForConnectionError: If client credentials are not configured, the exchange fails, or the returned token is already expired. - ApiError: If the token exchange endpoint returns an error. + GetAccessTokenForConnectionError: If client credentials are not configured or the returned token is already expired. + ApiError: If the token exchange endpoint is unreachable, times out, or returns an error. """ options: dict = { "connection": connection, diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/errors.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/errors.py index 5c87085..d610c56 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/errors.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/errors.py @@ -9,8 +9,15 @@ from auth0_api_python.errors import BaseAuthError +try: + from mcp.server.mcpserver.exceptions import ToolError +except ImportError: # Without mcp there is no framework error to extend. + _TOOL_ERROR_BASES: tuple[type[Exception], ...] = () +else: + _TOOL_ERROR_BASES = (ToolError,) -class UnauthenticatedError(BaseAuthError): + +class UnauthenticatedError(BaseAuthError, *_TOOL_ERROR_BASES): """No verified caller in the current request context. Raised inside the tool, so the framework reports it as a tool error.""" def get_status_code(self) -> int: @@ -20,7 +27,7 @@ def get_error_code(self) -> str: return "invalid_token" -class InsufficientScopeError(BaseAuthError): +class InsufficientScopeError(BaseAuthError, *_TOOL_ERROR_BASES): """The caller's token is missing a required scope. Raised inside the tool, so the framework reports it as a tool error.""" def get_status_code(self) -> int: diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py index 341ddc1..b51be33 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py @@ -17,6 +17,7 @@ from auth0_api_python.errors import ( AuthServerUnavailableError, + DomainsResolverError, VerifyAccessTokenError, ) from starlette.authentication import AuthCredentials @@ -163,11 +164,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: verified = await self._client.verify_access_token(token) principal = build_principal(verified.claims) except AuthServerUnavailableError as exc: - logger.error("Auth0 could not be reached to verify the token: %s", exc) + logger.error("Auth0 could not be reached to verify the token: %r", str(exc)) + await self._send_unavailable(send) + return + except DomainsResolverError as exc: + logger.warning("The domains resolver failed while verifying the token: %r", str(exc)) await self._send_unavailable(send) return except VerifyAccessTokenError as exc: - logger.warning("Token rejected: %s", exc) + logger.warning("Token rejected: %r", str(exc)) await self._send_unauthorized(send, error="invalid_token") return diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/principal.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/principal.py index 6e8bf48..1cc0c49 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/principal.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/principal.py @@ -20,7 +20,7 @@ class Principal: expires_at: Unix epoch seconds the token expires, from the "exp" claim. scopes: Parsed from the space-separated "scope" claim. Empty list if absent. permissions: From the "permissions" claim. None when the claim is absent from - the token (RBAC not enabled on the API), a list (possibly empty) when present. + the token (RBAC is off, or the API does not add permissions to the access token), a list (possibly empty) when present. client_id: From the "azp" claim, falling back to "client_id". Always set, because a token with neither is rejected. org_id: From the "org_id" claim. None when the token carries no Organization. """ diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/scopes.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/scopes.py index 330ff93..513af1e 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/scopes.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/scopes.py @@ -9,10 +9,13 @@ from __future__ import annotations import functools +import inspect import logging from collections.abc import Awaitable, Callable from typing import TypeVar +from auth0_api_python.errors import ConfigurationError + from .errors import InsufficientScopeError, UnauthenticatedError from .principal import Principal from .request_context import get_caller_principal @@ -31,7 +34,7 @@ def require_scopes(principal: Principal | None, scopes: str | list[str]) -> None scopes: A single required scope, or a list of scopes that must all be present. Raises: - ConfigurationError: If `scopes` is empty, blank, or holds a non-string or blank entry. + ConfigurationError: If `scopes` is empty, blank, or holds a non-string, blank or whitespace-containing entry. UnauthenticatedError: If `principal` is None. InsufficientScopeError: If the principal is missing any required scope. """ @@ -47,6 +50,8 @@ def require_scopes(principal: Principal | None, scopes: str | list[str]) -> None def with_required_scopes(scopes: str | list[str]) -> Callable[[F], F]: """Return a decorator that gates an async handler on the given scopes. + Put the framework's own registration decorator, such as `@mcp.tool()`, above this one. Registered the other way round, the framework keeps the unguarded function and the check is skipped. + Args: scopes: A single required scope, or a list of scopes that must all be present. @@ -54,11 +59,14 @@ def with_required_scopes(scopes: str | list[str]) -> Callable[[F], F]: A decorator that runs the scope check before the handler body. Raises: - ConfigurationError: At decoration time, if `scopes` is empty, blank, or holds a non-string or blank entry. + ConfigurationError: At decoration time, if `scopes` is empty, blank, or holds a non-string, blank or whitespace-containing entry, or if the decorated function is not async. """ required = scope_input.normalize_scopes(scopes) def decorator(func: F) -> F: + if not inspect.iscoroutinefunction(func): + raise ConfigurationError("with_required_scopes can only gate an async function") + @functools.wraps(func) async def wrapper(*args: object, **kwargs: object) -> object: require_scopes(get_caller_principal(), required) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py index a4a6247..7b6de92 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py @@ -13,11 +13,13 @@ def normalize_scopes(scopes: str | list[str]) -> list[str]: The required scopes as a list. Raises: - ConfigurationError: If `scopes` is empty, blank, not a string or list, or holds a non-string or blank entry. + ConfigurationError: If `scopes` is empty, blank, not a string or list, or holds a non-string, blank or whitespace-containing entry. """ required = [scopes] if isinstance(scopes, str) else scopes if not isinstance(required, list) or not required: raise ConfigurationError("scopes must be a non-empty string or a non-empty list of strings") if any(not isinstance(s, str) or not s.strip() for s in required): raise ConfigurationError("scopes must not contain blank or non-string entries") + if any(any(c.isspace() for c in s) for s in required): + raise ConfigurationError("each scope must be a single value without whitespace, so pass a list for several scopes") return list(required) diff --git a/packages/auth0-ai-mcp/tests/test_client.py b/packages/auth0-ai-mcp/tests/test_client.py index 862d9a5..ac7a89d 100644 --- a/packages/auth0-ai-mcp/tests/test_client.py +++ b/packages/auth0-ai-mcp/tests/test_client.py @@ -7,7 +7,12 @@ import pytest from auth0_api_python import ApiClient, ApiClientOptions, VerifiedToken -from auth0_api_python.errors import ApiError, ConfigurationError, GetTokenByExchangeProfileError +from auth0_api_python.errors import ( + ApiError, + ConfigurationError, + GetTokenByExchangeProfileError, + VerifyAccessTokenError, +) from auth0_ai_mcp import ( AbstractTokenStore, @@ -52,7 +57,9 @@ def __init__( raises=None, cc_scope_response=None, expires_at_override=_KEEP, + claims=None, ): + self.claims = claims self.granted_scope = granted_scope self.include_scope = include_scope self.expires_at = expires_at if expires_at is not None else int(time.time()) + 3600 @@ -70,6 +77,8 @@ def __init__( self.last_connection_options = None async def verify_access_token(self, access_token: str): + if self.claims is not None: + return self.claims return {"sub": "user_1", "exp": int(time.time()) + 3600, "scope": "mcp:use"} async def get_token_on_behalf_of(self, access_token, audience, scope=None, *, verified=None): @@ -169,6 +178,23 @@ async def test_verify_access_token_returns_verified_token_carrying_the_original_ assert verified.claims["sub"] == "user_1" +@pytest.mark.parametrize("cnf", [{"jkt": "thumbprint"}, {"x5t#S256": "thumbprint"}, {"jkt": ""}, "not-a-dict"]) +async def test_verify_access_token_rejects_a_sender_constrained_token(cnf): + client = McpServerClient(StubApiClient(claims={"sub": "user_1", "cnf": cnf})) + + with pytest.raises(VerifyAccessTokenError): + await client.verify_access_token("the.raw.token") + + +@pytest.mark.parametrize("cnf", [{}, None]) +async def test_verify_access_token_accepts_an_empty_cnf_claim(cnf): + client = McpServerClient(StubApiClient(claims={"sub": "user_1", "cnf": cnf})) + + verified = await client.verify_access_token("the.raw.token") + + assert verified.claims["sub"] == "user_1" + + async def test_exchange_forwards_verified_token_to_core(): """The verified token is forwarded so core exchanges its access token and skips re-verifying.""" api = StubApiClient() @@ -360,8 +386,9 @@ async def test_request_scoped_get_token_on_behalf_of_forwards_scope_and_verified verified = _verified() scoped = RequestScopedMcpClient(McpServerClient(api), verified, _principal()) - await scoped.get_token_on_behalf_of(audience="https://orders/", scope="cancel:orders") + result = await scoped.get_token_on_behalf_of(audience="https://orders/", scope="cancel:orders") + assert result["access_token"] == "downstream-for-https://orders/" assert api.last_scope == "cancel:orders" assert api.last_access_token == verified.access_token assert api.last_verified is verified @@ -434,6 +461,7 @@ def test_create_mcp_client_forwards_org_policy_to_core(): organization_policy="required", organization_id="org_abc", ) + assert client.audience == "https://api/" assert client._api.options.organization_policy == "required" assert client._api.options.organization_id == "org_abc" diff --git a/packages/auth0-ai-mcp/tests/test_middleware.py b/packages/auth0-ai-mcp/tests/test_middleware.py index 25fd87e..1795e44 100644 --- a/packages/auth0-ai-mcp/tests/test_middleware.py +++ b/packages/auth0-ai-mcp/tests/test_middleware.py @@ -10,6 +10,7 @@ from auth0_api_python.errors import ( AuthServerUnavailableError, ConfigurationError, + DomainsResolverError, VerifyAccessTokenError, ) from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser @@ -138,6 +139,25 @@ async def test_missing_bearer_token_returns_401_and_skips_app(): assert api.verified_tokens == [] +@pytest.mark.parametrize( + ("method", "path"), + [ + ("GET", "/mcp"), + ("DELETE", "/mcp"), + ("GET", "/.well-known/oauth-protected-resource/other"), + ], +) +async def test_missing_bearer_token_returns_401_for_other_methods_and_paths(method, path): + app = StubApp() + api = StubApiClient(claims=_claims()) + + sent = await _call(_middleware(app, api), _scope(path=path, method=method)) + + assert _status(sent) == 401 + assert app.calls == 0 + assert api.verified_tokens == [] + + async def test_non_bearer_authorization_header_is_treated_as_missing(): app = StubApp() @@ -186,6 +206,20 @@ async def test_challenge_uses_root_metadata_url_when_resource_has_no_path(): ) +async def test_challenge_keeps_the_port_of_the_resource_url(): + middleware = Auth0AsgiMiddleware( + StubApp(), + mcp_client=McpServerClient(StubApiClient(claims=_claims(), audience="http://localhost:3002/mcp")), + ) + + sent = await _call(middleware, _scope()) + + assert _status(sent) == 401 + assert b'resource_metadata="http://localhost:3002/.well-known/oauth-protected-resource/mcp"' in _header( + sent, b"www-authenticate" + ) + + def _unsigned_jwt(header, payload): def encode(value): return base64.urlsafe_b64encode(json.dumps(value).encode()).rstrip(b"=").decode() @@ -238,12 +272,23 @@ async def test_invalid_token_gets_a_fixed_description_and_the_reason_is_only_log assert "secret-tok" not in caplog.text +async def test_rejection_reason_is_escaped_in_the_log(caplog): + api = StubApiClient(raises=VerifyAccessTokenError("bad\r\nERROR forged record")) + + with caplog.at_level(logging.WARNING, logger="auth0_ai_mcp.middleware"): + sent = await _call(_middleware(StubApp(), api), _scope(headers=_bearer("tok"))) + + assert _status(sent) == 401 + assert "\r" not in caplog.text + assert "\\r\\nERROR forged record" in caplog.text + + async def test_unreachable_auth_server_returns_503_without_a_challenge_and_skips_app(caplog): app = StubApp() api = StubApiClient(raises=AuthServerUnavailableError("Failed to fetch JWKS")) with caplog.at_level(logging.ERROR, logger="auth0_ai_mcp.middleware"): - sent = await _call(_middleware(app, api), _scope(headers=_bearer("tok"))) + sent = await _call(_middleware(app, api), _scope(headers=_bearer("secret-tok"))) assert _status(sent) == 503 assert _header(sent, b"retry-after") == b"5" @@ -251,6 +296,45 @@ async def test_unreachable_auth_server_returns_503_without_a_challenge_and_skips assert _body(sent)["error"]["code"] == -32002 assert "Failed to fetch JWKS" not in json.dumps(_body(sent)) assert "Failed to fetch JWKS" in caplog.text + assert "secret-tok" not in caplog.text + assert app.calls == 0 + + +async def test_unreachable_auth_server_reason_is_escaped_in_the_log(caplog): + api = StubApiClient(raises=AuthServerUnavailableError("bad\r\nERROR forged record")) + + with caplog.at_level(logging.ERROR, logger="auth0_ai_mcp.middleware"): + sent = await _call(_middleware(StubApp(), api), _scope(headers=_bearer("tok"))) + + assert _status(sent) == 503 + assert "\r" not in caplog.text + assert "\\r\\nERROR forged record" in caplog.text + + +async def test_failing_domains_resolver_returns_503_without_a_challenge_and_skips_app(caplog): + app = StubApp() + api = StubApiClient(raises=DomainsResolverError("resolver\r\nERROR forged record")) + + with caplog.at_level(logging.WARNING, logger="auth0_ai_mcp.middleware"): + sent = await _call(_middleware(app, api), _scope(headers=_bearer("secret-tok"))) + + assert _status(sent) == 503 + assert _header(sent, b"www-authenticate") is None + assert "resolver" not in json.dumps(_body(sent)) + assert "\r" not in caplog.text + assert "\\r\\nERROR forged record" in caplog.text + assert "secret-tok" not in caplog.text + assert app.calls == 0 + + +async def test_dpop_bound_token_returns_401_and_skips_app(): + app = StubApp() + api = StubApiClient(claims={**_claims(), "cnf": {"jkt": "thumbprint"}}) + + sent = await _call(_middleware(app, api), _scope(headers=_bearer("tok"))) + + assert _status(sent) == 401 + assert 'error="invalid_token"' in _header(sent, b"www-authenticate").decode() assert app.calls == 0 diff --git a/packages/auth0-ai-mcp/tests/test_principal.py b/packages/auth0-ai-mcp/tests/test_principal.py index b9aae59..c85df56 100644 --- a/packages/auth0-ai-mcp/tests/test_principal.py +++ b/packages/auth0-ai-mcp/tests/test_principal.py @@ -67,6 +67,13 @@ def test_build_principal_missing_scope_claim_yields_empty_list(): assert principal.scopes == [] +@pytest.mark.parametrize("claim", [{"permissions": ["read:x"]}, {"scp": ["read:x"]}]) +def test_build_principal_ignores_other_claims_when_scope_is_missing(claim): + claims = {"sub": "auth0|user123", "exp": int(time.time()) + 3600, "azp": "spa_client_id", **claim} + + assert build_principal(claims).scopes == [] + + _ABSENT = object() diff --git a/packages/auth0-ai-mcp/tests/test_request_context.py b/packages/auth0-ai-mcp/tests/test_request_context.py index 06bb4b0..02227ed 100644 --- a/packages/auth0-ai-mcp/tests/test_request_context.py +++ b/packages/auth0-ai-mcp/tests/test_request_context.py @@ -1,5 +1,7 @@ """Tests for the request context binding and the get_caller_* accessors.""" +import asyncio + import pytest from auth0_ai_mcp import ( @@ -35,6 +37,29 @@ def test_context_is_reset_after_normal_exit(): assert get_caller_principal() is None +def test_nested_bind_restores_the_outer_context_on_exit(): + outer, inner = _context(), _context() + with bind_request_context(outer): + with bind_request_context(inner): + assert get_caller_principal() is inner.principal + assert get_caller_principal() is outer.principal + assert get_caller_principal() is None + + +async def test_concurrent_tasks_each_see_their_own_context(): + async def read_back(ctx): + with bind_request_context(ctx): + await asyncio.sleep(0) + return get_caller_principal() + + first, second = _context(), _context() + + seen = await asyncio.gather(read_back(first), read_back(second)) + + assert seen[0] is first.principal + assert seen[1] is second.principal + + def test_context_is_reset_after_an_exception(): with pytest.raises(RuntimeError), bind_request_context(_context()): raise RuntimeError("boom") diff --git a/packages/auth0-ai-mcp/tests/test_scopes.py b/packages/auth0-ai-mcp/tests/test_scopes.py index fcfb53b..049bfc2 100644 --- a/packages/auth0-ai-mcp/tests/test_scopes.py +++ b/packages/auth0-ai-mcp/tests/test_scopes.py @@ -1,13 +1,22 @@ """Tests for require_scopes and the with_required_scopes decorator.""" +import importlib.util import inspect +import sys import pytest -from auth0_api_python.errors import ConfigurationError +from auth0_api_python.errors import BaseAuthError, ConfigurationError +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError -from auth0_ai_mcp import Principal +from auth0_ai_mcp import Principal, errors from auth0_ai_mcp.errors import InsufficientScopeError, UnauthenticatedError -from auth0_ai_mcp.request_context import RequestContext, reset_request_context, set_request_context +from auth0_ai_mcp.request_context import ( + RequestContext, + bind_request_context, + reset_request_context, + set_request_context, +) from auth0_ai_mcp.scopes import require_scopes, with_required_scopes @@ -48,6 +57,15 @@ def test_raises_insufficient_scope_when_any_required_scope_is_missing(required): assert exc_info.value.get_error_code() == "insufficient_scope" +@pytest.mark.parametrize( + ("granted", "required"), + [("read:x:all", "read:x"), ("READ:X", "read:x"), ("read:x", "read:x:all")], +) +def test_a_scope_that_only_looks_like_the_required_one_does_not_satisfy_the_check(granted, required): + with pytest.raises(InsufficientScopeError): + require_scopes(_principal([granted]), required) + + def test_scope_held_only_as_a_permission_does_not_satisfy_the_check(): principal = Principal( sub="u1", expires_at=9999999999, scopes=[], permissions=["read:x"], client_id="client_1", org_id=None @@ -77,6 +95,14 @@ def test_rejects_empty_or_malformed_scopes(scopes: object): require_scopes(_principal(["read:x"]), scopes) # type: ignore[arg-type] +@pytest.mark.parametrize("scopes", ["read:a write:a", "read:a ", ["read:a", "write:a read:b"]]) +def test_rejects_a_scope_that_contains_whitespace(scopes: object): + with pytest.raises(ConfigurationError): + require_scopes(_principal(["read:a"]), scopes) # type: ignore[arg-type] + with pytest.raises(ConfigurationError): + with_required_scopes(scopes) # type: ignore[arg-type] + + @pytest.mark.parametrize(("required", "held"), [("read:x", ["read:x"]), (["read:x", "write:y"], ["read:x", "write:y"])]) async def test_handler_runs_when_principal_has_every_required_scope(required, held): ctx = _make_context(_principal(held)) @@ -131,6 +157,14 @@ def test_rejects_empty_or_malformed_scopes_at_decoration_time(scopes: object): with_required_scopes(scopes) # type: ignore[arg-type] +def test_rejects_a_sync_function_at_decoration_time(): + def sync_tool() -> str: + return "ran" + + with pytest.raises(ConfigurationError): + with_required_scopes("read:x")(sync_tool) # type: ignore[type-var] + + async def test_raises_unauthenticated_when_no_context_is_set(): # Do not set any context. The contextvar defaults to None. @with_required_scopes("read:x") @@ -150,3 +184,57 @@ async def handler(a: int, *, b: str = "x") -> str: assert handler.__name__ == "handler" assert handler.__doc__ == "Handler doc." assert list(inspect.signature(handler).parameters) == ["a", "b"] + + +async def test_denied_tool_reaches_the_client_as_a_tool_error_with_the_reason(): + mcp = MCPServer("test") + + @mcp.tool() + @with_required_scopes("read:x") + async def guarded() -> str: + return "body" + + with bind_request_context(_make_context(_principal(["other"]))): + with pytest.raises(ToolError) as exc_info: + await mcp.call_tool("guarded", {}) + + assert not isinstance(exc_info.value, UnexpectedToolError) + assert "Missing one or more required scopes." in str(exc_info.value) + assert isinstance(exc_info.value.__cause__, InsufficientScopeError) + + +async def test_tool_without_a_caller_reaches_the_client_as_a_tool_error_with_the_reason(): + mcp = MCPServer("test") + + @mcp.tool() + @with_required_scopes("read:x") + async def guarded() -> str: + return "body" + + with pytest.raises(ToolError) as exc_info: + await mcp.call_tool("guarded", {}) + + assert not isinstance(exc_info.value, UnexpectedToolError) + assert "No principal in the current request context." in str(exc_info.value) + assert isinstance(exc_info.value.__cause__, UnauthenticatedError) + + +@pytest.mark.parametrize("error", [InsufficientScopeError, UnauthenticatedError]) +def test_in_tool_errors_are_both_auth_errors_and_tool_errors(error): + exc = error("denied") + + assert isinstance(exc, BaseAuthError) + assert isinstance(exc, ToolError) + assert str(exc) == "denied" + assert exc.get_status_code() in (401, 403) + + +def test_in_tool_errors_stay_auth_errors_when_mcp_is_not_installed(monkeypatch): + monkeypatch.setitem(sys.modules, "mcp.server.mcpserver.exceptions", None) + spec = importlib.util.spec_from_file_location("errors_without_mcp", errors.__file__) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + for error in (module.InsufficientScopeError, module.UnauthenticatedError): + assert issubclass(error, BaseAuthError) + assert not issubclass(error, ToolError) From c278f1ab915e7a97d486bfc0be3254f6c52a9c1c Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 4 Nov 2026 11:56:32 +0530 Subject: [PATCH 4/7] fix: advertise scopes in the 401 challenge --- .../src/auth0_ai_mcp/middleware.py | 17 ++++++-- .../auth0-ai-mcp/tests/test_middleware.py | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py index b51be33..ee887a1 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py @@ -74,7 +74,10 @@ def build_protected_resource_metadata( def build_www_authenticate( - resource_metadata_url: str, error: str | None = None, error_description: str | None = None + resource_metadata_url: str, + error: str | None = None, + error_description: str | None = None, + scope: str | None = None, ) -> str: """Build a Bearer `WWW-Authenticate` challenge with every value made safe for a quoted header. @@ -82,6 +85,7 @@ def build_www_authenticate( resource_metadata_url: URL of the protected-resource metadata document. error: Optional RFC 6750 error code. Leave None when the request carried no token. error_description: Optional human-readable description, only sent alongside `error`. + scope: Optional space-separated scopes the client should request. Omitted from the challenge when None. Returns: The challenge, ASCII only, for example `Bearer resource_metadata="https://host/.well-known/oauth-protected-resource"`. @@ -92,6 +96,8 @@ def build_www_authenticate( if error_description is not None: params.append(f'error_description="{headers.header_safe(error_description)}"') params.append(f'resource_metadata="{headers.header_safe(resource_metadata_url)}"') + if scope is not None: + params.append(f'scope="{headers.header_safe(scope)}"') return "Bearer " + ", ".join(params) @@ -103,7 +109,7 @@ class Auth0AsgiMiddleware: mcp_client: The server client that verifies tokens. resource_url: Absolute http or https URL of the protected resource. Defaults to the client audience and must equal it when given. authorization_servers: Issuer URLs to advertise, each an absolute https URL. Defaults to the client's tenant domain and is required when the client has no single domain. - scopes_supported: Optional scopes to advertise in the metadata document. + scopes_supported: Optional scopes to advertise in the metadata document and in the `401` challenge. resource_name: Optional human-readable name for the resource, advertised in the metadata document. resource_documentation: Optional URL of documentation for the resource, advertised in the metadata document. @@ -136,6 +142,7 @@ def __init__( suffixed_path = _PRM_PATH + urls.resource_path(parts.path) self._metadata_paths = {_PRM_PATH, suffixed_path} self._metadata_url = f"{parts.scheme}://{parts.netloc}{suffixed_path}" + self._challenge_scope = " ".join(scopes_supported) if scopes_supported else None async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] == "websocket": @@ -200,10 +207,12 @@ async def _send_json( async def _send_unauthorized(self, send: Send, error: str | None = None) -> None: if error is None: - challenge = build_www_authenticate(self._metadata_url) + challenge = build_www_authenticate(self._metadata_url, scope=self._challenge_scope) message = "Unauthorized" else: - challenge = build_www_authenticate(self._metadata_url, error, _INVALID_DESCRIPTION) + challenge = build_www_authenticate( + self._metadata_url, error, _INVALID_DESCRIPTION, scope=self._challenge_scope + ) message = f"Unauthorized: {_INVALID_DESCRIPTION}" payload = { "jsonrpc": "2.0", diff --git a/packages/auth0-ai-mcp/tests/test_middleware.py b/packages/auth0-ai-mcp/tests/test_middleware.py index 1795e44..2119bf5 100644 --- a/packages/auth0-ai-mcp/tests/test_middleware.py +++ b/packages/auth0-ai-mcp/tests/test_middleware.py @@ -220,6 +220,33 @@ async def test_challenge_keeps_the_port_of_the_resource_url(): ) +async def test_challenge_lists_the_advertised_scopes_when_there_is_no_token(): + api = StubApiClient(claims=_claims()) + + sent = await _call(_middleware(StubApp(), api, scopes_supported=["read:x", "write:x"]), _scope()) + + assert _header(sent, b"www-authenticate") == ( + f'Bearer resource_metadata="{PRM_URL}", scope="read:x write:x"'.encode() + ) + + +async def test_invalid_token_challenge_lists_the_advertised_scopes(): + api = StubApiClient(raises=VerifyAccessTokenError("bad")) + + sent = await _call(_middleware(StubApp(), api, scopes_supported=["read:x"]), _scope(headers=_bearer("tok"))) + + assert _header(sent, b"www-authenticate") == ( + f'Bearer error="invalid_token", error_description="The access token is not valid", ' + f'resource_metadata="{PRM_URL}", scope="read:x"' + ).encode() + + +async def test_challenge_has_no_scope_when_the_advertised_list_is_empty(): + sent = await _call(_middleware(StubApp(), StubApiClient(claims=_claims()), scopes_supported=[]), _scope()) + + assert _header(sent, b"www-authenticate") == f'Bearer resource_metadata="{PRM_URL}"'.encode() + + def _unsigned_jwt(header, payload): def encode(value): return base64.urlsafe_b64encode(json.dumps(value).encode()).rstrip(b"=").decode() @@ -364,6 +391,18 @@ def test_build_www_authenticate_with_error(): ) +def test_build_www_authenticate_with_scope(): + assert build_www_authenticate(PRM_URL, scope="read:x write:x") == ( + f'Bearer resource_metadata="{PRM_URL}", scope="read:x write:x"' + ) + + +def test_build_www_authenticate_sanitizes_the_scope(): + challenge = build_www_authenticate(PRM_URL, scope='a"b\\\r\nX: y') + + assert challenge.endswith("scope=\"a'b X: y\"") + + def test_build_www_authenticate_sanitizes_every_value(): challenge = build_www_authenticate('https://h/"x\\', 'bad"\r\n', 'say "hi"\\\r\nX: y \u00e9\u2603') From 554861f79a83a6b2b19713c9081679e3f99ff6b7 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 4 Nov 2026 14:00:49 +0530 Subject: [PATCH 5/7] fix: reject malformed bearer values and validate advertised scopes - A bearer value with whitespace inside gets a 401 invalid_token without calling Auth0. - 401 and 503 responses carry Cache-Control: no-store. - An exchanged token with no usable expiry and one already expired now raise separate messages. - scopes_supported must be a list of single-value strings, otherwise ConfigurationError is raised at startup. - The mcp import fallback only applies when mcp is not installed, so a renamed class fails loudly. --- .../src/auth0_ai_mcp/middleware.py | 24 +++-- .../auth0_ai_mcp/utils/_internal/exchange.py | 6 +- .../auth0_ai_mcp/utils/_internal/headers.py | 5 ++ .../utils/_internal/mcp_session.py | 2 +- .../utils/_internal/scope_input.py | 17 ++++ packages/auth0-ai-mcp/tests/test_client.py | 13 ++- .../auth0-ai-mcp/tests/test_middleware.py | 89 +++++++++++++++++++ 7 files changed, 142 insertions(+), 14 deletions(-) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py index ee887a1..dd0b5ee 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py @@ -26,7 +26,7 @@ from .client import McpServerClient from .principal import build_principal from .request_context import RequestContext, bind_request_context -from .utils._internal import headers, mcp_session, urls +from .utils._internal import headers, mcp_session, scope_input, urls logger = logging.getLogger(__name__) @@ -57,7 +57,7 @@ def build_protected_resource_metadata( The metadata document as a dict. Raises: - ConfigurationError: If `resource_url` differs from the client audience or is not an absolute http or https URL, or if `authorization_servers` cannot be resolved or holds anything but absolute https URLs. + ConfigurationError: If `resource_url` differs from the client audience or is not an absolute http or https URL, or if `authorization_servers` cannot be resolved or holds anything but absolute https URLs, or if `scopes_supported` is not a list of single-value strings. """ doc: dict = { "resource": urls.resolve_resource_url(mcp_client, resource_url), @@ -65,7 +65,7 @@ def build_protected_resource_metadata( "bearer_methods_supported": ["header"], } if scopes_supported is not None: - doc["scopes_supported"] = scopes_supported + doc["scopes_supported"] = scope_input.normalize_advertised_scopes(scopes_supported) if resource_name is not None: doc["resource_name"] = resource_name if resource_documentation is not None: @@ -114,7 +114,7 @@ class Auth0AsgiMiddleware: resource_documentation: Optional URL of documentation for the resource, advertised in the metadata document. Raises: - ConfigurationError: If `resource_url` differs from the client audience or is not an absolute http or https URL, or if `authorization_servers` cannot be resolved or holds anything but absolute https URLs. + ConfigurationError: If `resource_url` differs from the client audience or is not an absolute http or https URL, or if `authorization_servers` cannot be resolved or holds anything but absolute https URLs, or if `scopes_supported` is not a list of single-value strings. """ def __init__( @@ -142,7 +142,7 @@ def __init__( suffixed_path = _PRM_PATH + urls.resource_path(parts.path) self._metadata_paths = {_PRM_PATH, suffixed_path} self._metadata_url = f"{parts.scheme}://{parts.netloc}{suffixed_path}" - self._challenge_scope = " ".join(scopes_supported) if scopes_supported else None + self._challenge_scope = " ".join(self._metadata.get("scopes_supported", [])) or None async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] == "websocket": @@ -167,6 +167,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self._send_unauthorized(send) return + if not headers.is_well_formed_token(token): + logger.warning("Token rejected: malformed bearer value") + await self._send_unauthorized(send, error="invalid_token") + return + try: verified = await self._client.verify_access_token(token) principal = build_principal(verified.claims) @@ -220,7 +225,10 @@ async def _send_unauthorized(self, send: Send, error: str | None = None) -> None "id": None, } await self._send_json( - send, 401, payload, headers=[(b"www-authenticate", challenge.encode("ascii"))] + send, + 401, + payload, + headers=[(b"www-authenticate", challenge.encode("ascii")), (b"cache-control", b"no-store")], ) async def _send_unavailable(self, send: Send) -> None: @@ -230,4 +238,6 @@ async def _send_unavailable(self, send: Send) -> None: "error": {"code": -32002, "message": "Service unavailable: the token could not be verified"}, "id": None, } - await self._send_json(send, 503, payload, headers=[(b"retry-after", b"5")]) + await self._send_json( + send, 503, payload, headers=[(b"retry-after", b"5"), (b"cache-control", b"no-store")] + ) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/exchange.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/exchange.py index 8e206d7..9b710a0 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/exchange.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/exchange.py @@ -20,9 +20,11 @@ def token_set(result: dict) -> TokenSet: def reject_if_expired(result: dict, error: type[BaseAuthError]) -> None: - """Raise `error` if the returned token has no usable `expires_at` or has already expired.""" + """Raise `error` if the returned token has no usable `expires_at` or has already expired, with a separate message for each.""" expires_at = result.get("expires_at") - if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)) or expires_at <= time.time(): + if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)): + raise error("The exchanged token has no usable expiry.") + if expires_at <= time.time(): raise error("The exchanged token is already expired.") diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/headers.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/headers.py index f872ff6..7007e11 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/headers.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/headers.py @@ -17,3 +17,8 @@ def bearer_token(scope: Scope) -> str | None: if len(parts) == 2 and parts[0].lower() == "bearer": return parts[1].strip() or None return None + + +def is_well_formed_token(token: str) -> bool: + """Whether the token has no whitespace inside it, so malformed values never reach verification.""" + return not any(ch.isspace() for ch in token) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/mcp_session.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/mcp_session.py index 81d9ae9..e41a937 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/mcp_session.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/mcp_session.py @@ -9,7 +9,7 @@ try: from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from mcp.server.auth.provider import AccessToken -except ImportError: # Without mcp there is no session manager to bind, so scope["user"] is skipped. +except ModuleNotFoundError: # Only a missing mcp is tolerated, so a renamed class fails loudly. AuthenticatedUser = None AccessToken = None diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py index 7b6de92..a22cd39 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/utils/_internal/scope_input.py @@ -23,3 +23,20 @@ def normalize_scopes(scopes: str | list[str]) -> list[str]: if any(any(c.isspace() for c in s) for s in required): raise ConfigurationError("each scope must be a single value without whitespace, so pass a list for several scopes") return list(required) + + +def normalize_advertised_scopes(scopes: list[str]) -> list[str]: + """Return the scopes to advertise, where an empty list means none are advertised. + + Args: + scopes: The scopes to advertise. + + Returns: + The scopes as a list. + + Raises: + ConfigurationError: If `scopes` is not a list, or holds a non-string, blank or whitespace-containing entry. + """ + if not isinstance(scopes, list): + raise ConfigurationError("scopes_supported must be a list of strings") + return normalize_scopes(scopes) if scopes else [] diff --git a/packages/auth0-ai-mcp/tests/test_client.py b/packages/auth0-ai-mcp/tests/test_client.py index ac7a89d..b7a3da4 100644 --- a/packages/auth0-ai-mcp/tests/test_client.py +++ b/packages/auth0-ai-mcp/tests/test_client.py @@ -274,14 +274,19 @@ async def test_exchange_rejection_propagates_core_error(): @pytest.mark.parametrize("flow", ["obo", "client_credentials", "token_vault"]) @pytest.mark.parametrize( - "expires_at", - [int(time.time()) - 1, _MISSING, None, "soon"], + ("expires_at", "message"), + [ + (int(time.time()) - 1, "already expired"), + (_MISSING, "no usable expiry"), + (None, "no usable expiry"), + ("soon", "no usable expiry"), + ], ids=["past", "missing", "none", "non_numeric"], ) -async def test_result_without_a_future_numeric_expires_at_is_rejected(flow, expires_at): +async def test_result_without_a_future_numeric_expires_at_is_rejected(flow, expires_at, message): client = McpServerClient(StubApiClient(expires_at_override=expires_at)) - with pytest.raises(_ERROR_BY_FLOW[flow]): + with pytest.raises(_ERROR_BY_FLOW[flow], match=message): await _run_flow(client, flow) diff --git a/packages/auth0-ai-mcp/tests/test_middleware.py b/packages/auth0-ai-mcp/tests/test_middleware.py index 2119bf5..64ddebe 100644 --- a/packages/auth0-ai-mcp/tests/test_middleware.py +++ b/packages/auth0-ai-mcp/tests/test_middleware.py @@ -1,8 +1,10 @@ """ASGI middleware behavior: bearer handling, request context, and protected-resource metadata.""" import base64 +import importlib import json import logging +import sys from types import SimpleNamespace import httpx @@ -13,6 +15,7 @@ DomainsResolverError, VerifyAccessTokenError, ) +from mcp.server.auth.middleware import bearer_auth from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from mcp.server.lowlevel import Server from mcp.server.streamable_http_manager import StreamableHTTPSessionManager @@ -29,6 +32,7 @@ build_protected_resource_metadata, build_www_authenticate, ) +from auth0_ai_mcp.utils._internal import mcp_session RESOURCE_URL = "https://mcp.example/mcp" DOMAIN = "tenant.example" @@ -183,6 +187,25 @@ async def test_blank_bearer_token_is_treated_as_missing(value): assert api.verified_tokens == [] +@pytest.mark.parametrize( + "token", + ["two parts", "tab\tinside", "line\nbreak", "non\xa0breaking"], + ids=["space", "tab", "newline", "nbsp"], +) +async def test_malformed_bearer_value_gets_invalid_token_without_calling_core(token, caplog): + app = StubApp() + api = StubApiClient(claims=_claims()) + + with caplog.at_level(logging.WARNING, logger="auth0_ai_mcp.middleware"): + sent = await _call(_middleware(app, api), _scope(headers=_bearer(token))) + + assert _status(sent) == 401 + assert b'error="invalid_token"' in _header(sent, b"www-authenticate") + assert app.calls == 0 + assert api.verified_tokens == [] + assert token not in caplog.text + + async def test_bearer_scheme_is_matched_case_insensitively(): app = StubApp() api = StubApiClient(claims=_claims()) @@ -327,6 +350,27 @@ async def test_unreachable_auth_server_returns_503_without_a_challenge_and_skips assert app.calls == 0 +@pytest.mark.parametrize("raises", [None, VerifyAccessTokenError("bad"), AuthServerUnavailableError("down")]) +async def test_error_responses_are_not_cacheable(raises): + api = StubApiClient(claims=_claims(), raises=raises) + headers = [] if raises is None else _bearer("tok") + + sent = await _call(_middleware(StubApp(), api), _scope(headers=headers)) + + assert _status(sent) in (401, 503) + assert _header(sent, b"cache-control") == b"no-store" + + +async def test_metadata_document_is_not_marked_no_store(): + sent = await _call( + _middleware(StubApp(), StubApiClient(claims=_claims())), + _scope(path="/.well-known/oauth-protected-resource/mcp", method="GET"), + ) + + assert _status(sent) == 200 + assert _header(sent, b"cache-control") is None + + async def test_unreachable_auth_server_reason_is_escaped_in_the_log(caplog): api = StubApiClient(raises=AuthServerUnavailableError("bad\r\nERROR forged record")) @@ -520,6 +564,30 @@ async def test_mcp_session_stays_bound_to_the_caller_that_opened_it(): assert same.status_code == 200 +@pytest.fixture +def reloaded_mcp_session(monkeypatch): + yield lambda: importlib.reload(mcp_session) + monkeypatch.undo() + importlib.reload(mcp_session) + + +def test_session_binding_is_skipped_when_mcp_is_not_installed(monkeypatch, reloaded_mcp_session): + monkeypatch.setitem(sys.modules, "mcp", None) + monkeypatch.setitem(sys.modules, "mcp.server.auth.middleware.bearer_auth", None) + + module = reloaded_mcp_session() + + assert module.AuthenticatedUser is None + assert module.AccessToken is None + + +def test_a_renamed_mcp_class_fails_loudly_instead_of_skipping_session_binding(monkeypatch, reloaded_mcp_session): + monkeypatch.delattr(bearer_auth, "AuthenticatedUser") + + with pytest.raises(ImportError): + reloaded_mcp_session() + + async def test_context_is_reset_after_request(): await _call( _middleware(StubApp(), StubApiClient(claims=_claims())), @@ -600,6 +668,27 @@ def test_metadata_omits_scopes_supported_when_not_given(): assert "scopes_supported" not in doc +def test_metadata_advertises_an_empty_scope_list_as_given(): + doc = build_protected_resource_metadata(McpServerClient(StubApiClient()), scopes_supported=[]) + + assert doc["scopes_supported"] == [] + + +@pytest.mark.parametrize( + "scopes", + ["read:x", ("read:x",), ["read:x", ""], ["read:x", " "], ["read:x write:x"], ["read:x", 1], [None]], + ids=["bare_string", "tuple", "blank", "whitespace_only", "inner_space", "non_string", "none_entry"], +) +def test_metadata_rejects_malformed_scopes_supported(scopes): + with pytest.raises(ConfigurationError, match="scope"): + build_protected_resource_metadata(McpServerClient(StubApiClient()), scopes_supported=scopes) + + +def test_middleware_rejects_malformed_scopes_supported_at_startup(): + with pytest.raises(ConfigurationError, match="scope"): + _middleware(StubApp(), StubApiClient(), scopes_supported="read:x") + + def test_metadata_omits_resource_name_and_documentation_when_not_given(): doc = build_protected_resource_metadata(McpServerClient(StubApiClient())) From 1d77893c0b5943e123fbe233929591609d583efd Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Thu, 5 Nov 2026 19:28:43 +0530 Subject: [PATCH 6/7] fix: use JSON-RPC code -32003 for the service unavailable body --- packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py | 2 +- packages/auth0-ai-mcp/tests/test_middleware.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py b/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py index dd0b5ee..841850a 100644 --- a/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py +++ b/packages/auth0-ai-mcp/src/auth0_ai_mcp/middleware.py @@ -235,7 +235,7 @@ async def _send_unavailable(self, send: Send) -> None: # No challenge, so the client keeps its token and retries instead of logging in again. payload = { "jsonrpc": "2.0", - "error": {"code": -32002, "message": "Service unavailable: the token could not be verified"}, + "error": {"code": -32003, "message": "Service unavailable: the token could not be verified"}, "id": None, } await self._send_json( diff --git a/packages/auth0-ai-mcp/tests/test_middleware.py b/packages/auth0-ai-mcp/tests/test_middleware.py index 64ddebe..9cba1bc 100644 --- a/packages/auth0-ai-mcp/tests/test_middleware.py +++ b/packages/auth0-ai-mcp/tests/test_middleware.py @@ -343,7 +343,7 @@ async def test_unreachable_auth_server_returns_503_without_a_challenge_and_skips assert _status(sent) == 503 assert _header(sent, b"retry-after") == b"5" assert _header(sent, b"www-authenticate") is None - assert _body(sent)["error"]["code"] == -32002 + assert _body(sent)["error"]["code"] == -32003 assert "Failed to fetch JWKS" not in json.dumps(_body(sent)) assert "Failed to fetch JWKS" in caplog.text assert "secret-tok" not in caplog.text From 6fd63c50dfd19c57426a3a937fe8f5d965f4760a Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Thu, 5 Nov 2026 19:28:44 +0530 Subject: [PATCH 7/7] chore: use the repo license text in the package --- packages/auth0-ai-mcp/LICENSE | 226 ++++++++++++++++++++++++++-------- 1 file changed, 173 insertions(+), 53 deletions(-) diff --git a/packages/auth0-ai-mcp/LICENSE b/packages/auth0-ai-mcp/LICENSE index 86370bd..e6815d1 100644 --- a/packages/auth0-ai-mcp/LICENSE +++ b/packages/auth0-ai-mcp/LICENSE @@ -1,56 +1,176 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -1. Definitions. - -"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. - -"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. - -"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - -"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. - -"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. - -"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. - -"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). - -"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. - -"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." - -"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: - -You must give any other recipients of the Work or Derivative Works a copy of this License; and -You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and -If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -Copyright 2026 Okta, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.You may obtain a copy of the License at: -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. \ No newline at end of file +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS \ No newline at end of file